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
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
@ -13,5 +13,8 @@
|
||||||
"[vue]": {
|
"[vue]": {
|
||||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"files.eol": "\n",
|
||||||
|
"files.trimTrailingWhitespace": true,
|
||||||
|
"editor.trimAutoWhitespace": true
|
||||||
}
|
}
|
186
eslint.config.js
186
eslint.config.js
|
@ -1,91 +1,97 @@
|
||||||
import stylistic from '@stylistic/eslint-plugin';
|
import stylistic from '@stylistic/eslint-plugin';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import pluginVue from 'eslint-plugin-vue';
|
import pluginVue from 'eslint-plugin-vue';
|
||||||
import pluginQuasar from '@quasar/app-vite/eslint';
|
import pluginQuasar from '@quasar/app-vite/eslint';
|
||||||
|
|
||||||
export default
|
export default
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Ignore the following files.
|
* Ignore the following files.
|
||||||
* Please note that pluginQuasar.configs.recommended() already ignores
|
* Please note that pluginQuasar.configs.recommended() already ignores
|
||||||
* the "node_modules" folder for you (and all other Quasar project
|
* the "node_modules" folder for you (and all other Quasar project
|
||||||
* relevant folders and files).
|
* relevant folders and files).
|
||||||
*
|
*
|
||||||
* ESLint requires "ignores" key to be the only one in this object
|
* ESLint requires "ignores" key to be the only one in this object
|
||||||
*/
|
*/
|
||||||
// ignores: []
|
// ignores: []
|
||||||
},
|
},
|
||||||
|
|
||||||
...pluginQuasar.configs.recommended(),
|
...pluginQuasar.configs.recommended(),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* https://eslint.vuejs.org
|
* https://eslint.vuejs.org
|
||||||
*
|
*
|
||||||
* pluginVue.configs.base
|
* pluginVue.configs.base
|
||||||
* -> Settings and rules to enable correct ESLint parsing.
|
* -> Settings and rules to enable correct ESLint parsing.
|
||||||
* pluginVue.configs[ 'flat/essential']
|
* pluginVue.configs[ 'flat/essential']
|
||||||
* -> base, plus rules to prevent errors or unintended behavior.
|
* -> base, plus rules to prevent errors or unintended behavior.
|
||||||
* pluginVue.configs["flat/strongly-recommended"]
|
* pluginVue.configs["flat/strongly-recommended"]
|
||||||
* -> Above, plus rules to considerably improve code readability and/or dev experience.
|
* -> Above, plus rules to considerably improve code readability and/or dev experience.
|
||||||
* pluginVue.configs["flat/recommended"]
|
* pluginVue.configs["flat/recommended"]
|
||||||
* -> Above, plus rules to enforce subjective community defaults to ensure consistency.
|
* -> Above, plus rules to enforce subjective community defaults to ensure consistency.
|
||||||
*/
|
*/
|
||||||
...pluginVue.configs['flat/essential'],
|
...pluginVue.configs['flat/essential'],
|
||||||
...pluginVue.configs['flat/strongly-recommended'],
|
...pluginVue.configs['flat/strongly-recommended'],
|
||||||
|
|
||||||
{
|
{
|
||||||
plugins: {
|
plugins: {
|
||||||
'@stylistic': stylistic,
|
'@stylistic': stylistic,
|
||||||
},
|
},
|
||||||
languageOptions:
|
languageOptions:
|
||||||
{
|
{
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
|
|
||||||
globals:
|
globals:
|
||||||
{
|
{
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node, // SSR, Electron, config files
|
...globals.node, // SSR, Electron, config files
|
||||||
process: 'readonly', // process.env.*
|
process: 'readonly', // process.env.*
|
||||||
ga: 'readonly', // Google Analytics
|
ga: 'readonly', // Google Analytics
|
||||||
cordova: 'readonly',
|
cordova: 'readonly',
|
||||||
Capacitor: 'readonly',
|
Capacitor: 'readonly',
|
||||||
chrome: 'readonly', // BEX related
|
chrome: 'readonly', // BEX related
|
||||||
browser: 'readonly' // BEX related
|
browser: 'readonly' // BEX related
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// add your custom rules here
|
// add your custom rules here
|
||||||
rules:
|
rules:
|
||||||
{
|
{
|
||||||
'prefer-promise-reject-errors': 'off',
|
'prefer-promise-reject-errors': 'off',
|
||||||
|
|
||||||
// allow debugger during development only
|
// allow debugger during development only
|
||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||||
|
|
||||||
// enforce Allman brace style
|
// enforce Allman brace style
|
||||||
'@stylistic/brace-style': ['warn', 'allman'],
|
'@stylistic/brace-style': ['warn', 'allman'],
|
||||||
'@stylistic/indent': ['warn', 2],
|
'@stylistic/indent': ['warn', 2],
|
||||||
|
|
||||||
//Enforce single quotes
|
//Enforce single quotes
|
||||||
'@stylistic/quotes': ['warn', 'single', { avoidEscape: true }],
|
'@stylistic/quotes': ['warn', 'single', { avoidEscape: true }],
|
||||||
'@stylistic/quote-props': ['warn', 'as-needed', { keywords: true, unnecessary: true, numbers: true }],
|
'@stylistic/quote-props': ['warn', 'as-needed', { keywords: true, unnecessary: true, numbers: true }],
|
||||||
|
|
||||||
//Enforce semicolon
|
//Enforce semicolon
|
||||||
'@stylistic/semi': ['warn', 'always'],
|
'@stylistic/semi': ['warn', 'always'],
|
||||||
'@stylistic/space-before-function-paren': ['warn', 'never'],
|
'@stylistic/space-before-function-paren': ['warn', 'never'],
|
||||||
}
|
|
||||||
},
|
//Force LF and not CRLF
|
||||||
|
'@stylistic/linebreak-style': ['warn', 'unix'],
|
||||||
{
|
|
||||||
files: ['src-pwa/custom-service-worker.js'],
|
//Force no trailing spaces
|
||||||
languageOptions:
|
'@stylistic/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
|
||||||
{
|
}
|
||||||
globals:
|
},
|
||||||
{
|
|
||||||
...globals.serviceworker
|
{
|
||||||
}
|
files: ['src-pwa/custom-service-worker.js'],
|
||||||
}
|
languageOptions:
|
||||||
}
|
{
|
||||||
|
globals:
|
||||||
|
{
|
||||||
|
...globals.serviceworker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
];
|
];
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import { defineConfig } from '#q-app/wrappers';
|
import { defineConfig } from '#q-app/wrappers';
|
||||||
|
|
||||||
export default defineConfig((/* ctx */) =>
|
export default defineConfig((/* ctx */) =>
|
||||||
{
|
{
|
||||||
return {
|
return {
|
||||||
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
|
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
|
||||||
|
@ -23,7 +23,7 @@ export default defineConfig((/* ctx */) =>
|
||||||
// https://github.com/quasarframework/quasar/tree/dev/extras
|
// https://github.com/quasarframework/quasar/tree/dev/extras
|
||||||
extras: [
|
extras: [
|
||||||
// 'ionicons-v4',
|
// 'ionicons-v4',
|
||||||
// 'mdi-v7',
|
'mdi-v7',
|
||||||
// 'fontawesome-v6',
|
// 'fontawesome-v6',
|
||||||
// 'eva-icons',
|
// 'eva-icons',
|
||||||
// 'themify',
|
// 'themify',
|
||||||
|
@ -59,7 +59,7 @@ export default defineConfig((/* ctx */) =>
|
||||||
|
|
||||||
// extendViteConf (viteConf) {},
|
// extendViteConf (viteConf) {},
|
||||||
// viteVuePluginOptions: {},
|
// viteVuePluginOptions: {},
|
||||||
|
|
||||||
// vitePlugins: [
|
// vitePlugins: [
|
||||||
// [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ]
|
// [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ]
|
||||||
// ]
|
// ]
|
||||||
|
@ -77,7 +77,7 @@ export default defineConfig((/* ctx */) =>
|
||||||
devServer: {
|
devServer: {
|
||||||
// https: true,
|
// https: true,
|
||||||
open: true, // opens browser window automatically
|
open: true, // opens browser window automatically
|
||||||
|
|
||||||
//Add a proxy from /api to the backend server for dev usage
|
//Add a proxy from /api to the backend server for dev usage
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
// Instantiate Prisma Client
|
// Instantiate Prisma Client
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// Export the Prisma Client instance for use in other modules
|
// Export the Prisma Client instance for use in other modules
|
||||||
export default prisma;
|
export default prisma;
|
|
@ -1,12 +1,12 @@
|
||||||
// src-ssr/middlewares/authMiddleware.js
|
// src-ssr/middlewares/authMiddleware.js
|
||||||
|
|
||||||
export function requireAuth(req, res, next)
|
export function requireAuth(req, res, next)
|
||||||
{
|
{
|
||||||
if (!req.session || !req.session.loggedInUserId)
|
if (!req.session || !req.session.loggedInUserId)
|
||||||
{
|
{
|
||||||
// User is not authenticated
|
// User is not authenticated
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
}
|
}
|
||||||
// User is authenticated, proceed to the next middleware or route handler
|
// User is authenticated, proceed to the next middleware or route handler
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,459 +1,459 @@
|
||||||
// src-ssr/routes/auth.js
|
// src-ssr/routes/auth.js
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import {
|
import {
|
||||||
generateRegistrationOptions,
|
generateRegistrationOptions,
|
||||||
verifyRegistrationResponse,
|
verifyRegistrationResponse,
|
||||||
generateAuthenticationOptions,
|
generateAuthenticationOptions,
|
||||||
verifyAuthenticationResponse,
|
verifyAuthenticationResponse,
|
||||||
} from '@simplewebauthn/server';
|
} from '@simplewebauthn/server';
|
||||||
import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already
|
import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already
|
||||||
import prisma from '../database.js';
|
import prisma from '../database.js';
|
||||||
import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store
|
import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Helper function to get user authenticators
|
// Helper function to get user authenticators
|
||||||
async function getUserAuthenticators(userId)
|
async function getUserAuthenticators(userId)
|
||||||
{
|
{
|
||||||
return prisma.authenticator.findMany({
|
return prisma.authenticator.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
select: {
|
select: {
|
||||||
credentialID: true,
|
credentialID: true,
|
||||||
credentialPublicKey: true,
|
credentialPublicKey: true,
|
||||||
counter: true,
|
counter: true,
|
||||||
transports: true,
|
transports: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get a user by username
|
// Helper function to get a user by username
|
||||||
async function getUserByUsername(username)
|
async function getUserByUsername(username)
|
||||||
{
|
{
|
||||||
return prisma.user.findUnique({ where: { username } });
|
return prisma.user.findUnique({ where: { username } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get a user by ID
|
// Helper function to get a user by ID
|
||||||
async function getUserById(id)
|
async function getUserById(id)
|
||||||
{
|
{
|
||||||
return prisma.user.findUnique({ where: { id } });
|
return prisma.user.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get an authenticator by credential ID
|
// Helper function to get an authenticator by credential ID
|
||||||
async function getAuthenticatorByCredentialID(credentialID)
|
async function getAuthenticatorByCredentialID(credentialID)
|
||||||
{
|
{
|
||||||
return prisma.authenticator.findUnique({ where: { credentialID } });
|
return prisma.authenticator.findUnique({ where: { credentialID } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Generate Registration Options
|
// Generate Registration Options
|
||||||
router.post('/generate-registration-options', async(req, res) =>
|
router.post('/generate-registration-options', async(req, res) =>
|
||||||
{
|
{
|
||||||
const { username } = req.body;
|
const { username } = req.body;
|
||||||
|
|
||||||
if (!username)
|
if (!username)
|
||||||
{
|
{
|
||||||
return res.status(400).json({ error: 'Username is required' });
|
return res.status(400).json({ error: 'Username is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
let user = await getUserByUsername(username);
|
let user = await getUserByUsername(username);
|
||||||
|
|
||||||
// If user doesn't exist, create one
|
// If user doesn't exist, create one
|
||||||
if (!user)
|
if (!user)
|
||||||
{
|
{
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: { username },
|
data: { username },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const userAuthenticators = await getUserAuthenticators(user.id);
|
const userAuthenticators = await getUserAuthenticators(user.id);
|
||||||
|
|
||||||
if(userAuthenticators.length > 0)
|
if(userAuthenticators.length > 0)
|
||||||
{
|
{
|
||||||
//The user is trying to register a new authenticator, so we need to check if the user registering is the same as the one in the session
|
//The user is trying to register a new authenticator, so we need to check if the user registering is the same as the one in the session
|
||||||
if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id)
|
if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id)
|
||||||
{
|
{
|
||||||
return res.status(403).json({ error: 'Invalid registration attempt.' });
|
return res.status(403).json({ error: 'Invalid registration attempt.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = await generateRegistrationOptions({
|
const options = await generateRegistrationOptions({
|
||||||
rpName,
|
rpName,
|
||||||
rpID,
|
rpID,
|
||||||
userName: user.username,
|
userName: user.username,
|
||||||
// Don't prompt users for additional authenticators if they've already registered some
|
// Don't prompt users for additional authenticators if they've already registered some
|
||||||
excludeCredentials: userAuthenticators.map(auth => ({
|
excludeCredentials: userAuthenticators.map(auth => ({
|
||||||
id: auth.credentialID, // Use isoBase64URL helper
|
id: auth.credentialID, // Use isoBase64URL helper
|
||||||
type: 'public-key',
|
type: 'public-key',
|
||||||
// Optional: Specify transports if you know them
|
// Optional: Specify transports if you know them
|
||||||
transports: auth.transports ? auth.transports.split(',') : undefined,
|
transports: auth.transports ? auth.transports.split(',') : undefined,
|
||||||
})),
|
})),
|
||||||
authenticatorSelection: {
|
authenticatorSelection: {
|
||||||
// Defaults
|
// Defaults
|
||||||
residentKey: 'required',
|
residentKey: 'required',
|
||||||
userVerification: 'preferred',
|
userVerification: 'preferred',
|
||||||
},
|
},
|
||||||
// Strong advice: Always require attestation for registration
|
// Strong advice: Always require attestation for registration
|
||||||
attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production
|
attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store the challenge
|
// Store the challenge
|
||||||
challengeStore.set(user.id, options.challenge);
|
challengeStore.set(user.id, options.challenge);
|
||||||
req.session.userId = user.id; // Temporarily store userId in session for verification step
|
req.session.userId = user.id; // Temporarily store userId in session for verification step
|
||||||
|
|
||||||
res.json(options);
|
res.json(options);
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Registration options error:', error);
|
console.error('Registration options error:', error);
|
||||||
res.status(500).json({ error: 'Failed to generate registration options' });
|
res.status(500).json({ error: 'Failed to generate registration options' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify Registration
|
// Verify Registration
|
||||||
router.post('/verify-registration', async(req, res) =>
|
router.post('/verify-registration', async(req, res) =>
|
||||||
{
|
{
|
||||||
const { registrationResponse } = req.body;
|
const { registrationResponse } = req.body;
|
||||||
const userId = req.session.userId; // Retrieve userId stored during options generation
|
const userId = req.session.userId; // Retrieve userId stored during options generation
|
||||||
|
|
||||||
if (!userId)
|
if (!userId)
|
||||||
{
|
{
|
||||||
return res.status(400).json({ error: 'User session not found. Please start registration again.' });
|
return res.status(400).json({ error: 'User session not found. Please start registration again.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedChallenge = challengeStore.get(userId);
|
const expectedChallenge = challengeStore.get(userId);
|
||||||
|
|
||||||
if (!expectedChallenge)
|
if (!expectedChallenge)
|
||||||
{
|
{
|
||||||
return res.status(400).json({ error: 'Challenge not found or expired' });
|
return res.status(400).json({ error: 'Challenge not found or expired' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const user = await getUserById(userId);
|
const user = await getUserById(userId);
|
||||||
if (!user)
|
if (!user)
|
||||||
{
|
{
|
||||||
return res.status(404).json({ error: 'User not found' });
|
return res.status(404).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const verification = await verifyRegistrationResponse({
|
const verification = await verifyRegistrationResponse({
|
||||||
response: registrationResponse,
|
response: registrationResponse,
|
||||||
expectedChallenge: expectedChallenge,
|
expectedChallenge: expectedChallenge,
|
||||||
expectedOrigin: origin,
|
expectedOrigin: origin,
|
||||||
expectedRPID: rpID,
|
expectedRPID: rpID,
|
||||||
requireUserVerification: false, // Adjust based on your requirements
|
requireUserVerification: false, // Adjust based on your requirements
|
||||||
});
|
});
|
||||||
|
|
||||||
const { verified, registrationInfo } = verification;
|
const { verified, registrationInfo } = verification;
|
||||||
|
|
||||||
console.log(verification);
|
console.log(verification);
|
||||||
|
|
||||||
if (verified && registrationInfo)
|
if (verified && registrationInfo)
|
||||||
{
|
{
|
||||||
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
|
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
|
||||||
|
|
||||||
const credentialID = credential.id;
|
const credentialID = credential.id;
|
||||||
const credentialPublicKey = credential.publicKey;
|
const credentialPublicKey = credential.publicKey;
|
||||||
const counter = credential.counter;
|
const counter = credential.counter;
|
||||||
const transports = credential.transports || []; // Use empty array if transports are not provided
|
const transports = credential.transports || []; // Use empty array if transports are not provided
|
||||||
|
|
||||||
// Check if authenticator with this ID already exists
|
// Check if authenticator with this ID already exists
|
||||||
const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID));
|
const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID));
|
||||||
|
|
||||||
if (existingAuthenticator)
|
if (existingAuthenticator)
|
||||||
{
|
{
|
||||||
return res.status(409).json({ error: 'Authenticator already registered' });
|
return res.status(409).json({ error: 'Authenticator already registered' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the authenticator
|
// Save the authenticator
|
||||||
await prisma.authenticator.create({
|
await prisma.authenticator.create({
|
||||||
data: {
|
data: {
|
||||||
credentialID, // Store as Base64URL string
|
credentialID, // Store as Base64URL string
|
||||||
credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes
|
credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes
|
||||||
counter: BigInt(counter), // Store as BigInt
|
counter: BigInt(counter), // Store as BigInt
|
||||||
credentialDeviceType,
|
credentialDeviceType,
|
||||||
credentialBackedUp,
|
credentialBackedUp,
|
||||||
transports: transports.join(','), // Store transports as comma-separated string
|
transports: transports.join(','), // Store transports as comma-separated string
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the challenge and temporary userId
|
// Clear the challenge and temporary userId
|
||||||
challengeStore.delete(userId);
|
challengeStore.delete(userId);
|
||||||
delete req.session.userId;
|
delete req.session.userId;
|
||||||
|
|
||||||
// Log the user in by setting the final session userId
|
// Log the user in by setting the final session userId
|
||||||
req.session.loggedInUserId = user.id;
|
req.session.loggedInUserId = user.id;
|
||||||
|
|
||||||
res.json({ verified: true });
|
res.json({ verified: true });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
res.status(400).json({ error: 'Registration verification failed' });
|
res.status(400).json({ error: 'Registration verification failed' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Registration verification error:', error);
|
console.error('Registration verification error:', error);
|
||||||
challengeStore.delete(userId); // Clean up challenge on error
|
challengeStore.delete(userId); // Clean up challenge on error
|
||||||
delete req.session.userId;
|
delete req.session.userId;
|
||||||
res.status(500).json({ error: 'Failed to verify registration', details: error.message });
|
res.status(500).json({ error: 'Failed to verify registration', details: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate Authentication Options
|
// Generate Authentication Options
|
||||||
router.post('/generate-authentication-options', async(req, res) =>
|
router.post('/generate-authentication-options', async(req, res) =>
|
||||||
{
|
{
|
||||||
const { username } = req.body;
|
const { username } = req.body;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
let user;
|
let user;
|
||||||
if (username)
|
if (username)
|
||||||
{
|
{
|
||||||
user = await getUserByUsername(username);
|
user = await getUserByUsername(username);
|
||||||
}
|
}
|
||||||
else if (req.session.loggedInUserId)
|
else if (req.session.loggedInUserId)
|
||||||
{
|
{
|
||||||
// If already logged in, allow re-authentication (e.g., for step-up)
|
// If already logged in, allow re-authentication (e.g., for step-up)
|
||||||
user = await getUserById(req.session.loggedInUserId);
|
user = await getUserById(req.session.loggedInUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user)
|
if (!user)
|
||||||
{
|
{
|
||||||
return res.status(404).json({ error: 'User not found' });
|
return res.status(404).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('User found:', user);
|
console.log('User found:', user);
|
||||||
|
|
||||||
const userAuthenticators = await getUserAuthenticators(user.id);
|
const userAuthenticators = await getUserAuthenticators(user.id);
|
||||||
|
|
||||||
console.log('User authenticators:', userAuthenticators);
|
console.log('User authenticators:', userAuthenticators);
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
const options = await generateAuthenticationOptions({
|
||||||
rpID,
|
rpID,
|
||||||
// Require users to use a previously-registered authenticator
|
// Require users to use a previously-registered authenticator
|
||||||
allowCredentials: userAuthenticators.map(auth => ({
|
allowCredentials: userAuthenticators.map(auth => ({
|
||||||
id: auth.credentialID,
|
id: auth.credentialID,
|
||||||
type: 'public-key',
|
type: 'public-key',
|
||||||
transports: auth.transports ? auth.transports.split(',') : undefined,
|
transports: auth.transports ? auth.transports.split(',') : undefined,
|
||||||
})),
|
})),
|
||||||
userVerification: 'preferred',
|
userVerification: 'preferred',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store the challenge associated with the user ID for verification
|
// Store the challenge associated with the user ID for verification
|
||||||
challengeStore.set(user.id, options.challenge);
|
challengeStore.set(user.id, options.challenge);
|
||||||
req.session.challengeUserId = user.id; // Store user ID associated with this challenge
|
req.session.challengeUserId = user.id; // Store user ID associated with this challenge
|
||||||
|
|
||||||
res.json(options);
|
res.json(options);
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Authentication options error:', error);
|
console.error('Authentication options error:', error);
|
||||||
res.status(500).json({ error: 'Failed to generate authentication options' });
|
res.status(500).json({ error: 'Failed to generate authentication options' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify Authentication
|
// Verify Authentication
|
||||||
router.post('/verify-authentication', async(req, res) =>
|
router.post('/verify-authentication', async(req, res) =>
|
||||||
{
|
{
|
||||||
const { authenticationResponse } = req.body;
|
const { authenticationResponse } = req.body;
|
||||||
const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge
|
const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge
|
||||||
|
|
||||||
if (!challengeUserId)
|
if (!challengeUserId)
|
||||||
{
|
{
|
||||||
return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' });
|
return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedChallenge = challengeStore.get(challengeUserId);
|
const expectedChallenge = challengeStore.get(challengeUserId);
|
||||||
|
|
||||||
if (!expectedChallenge)
|
if (!expectedChallenge)
|
||||||
{
|
{
|
||||||
return res.status(400).json({ error: 'Challenge not found or expired' });
|
return res.status(400).json({ error: 'Challenge not found or expired' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const user = await getUserById(challengeUserId);
|
const user = await getUserById(challengeUserId);
|
||||||
if (!user)
|
if (!user)
|
||||||
{
|
{
|
||||||
return res.status(404).json({ error: 'User associated with challenge not found' });
|
return res.status(404).json({ error: 'User associated with challenge not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id);
|
const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id);
|
||||||
|
|
||||||
if (!authenticator)
|
if (!authenticator)
|
||||||
{
|
{
|
||||||
return res.status(404).json({ error: 'Authenticator not found' });
|
return res.status(404).json({ error: 'Authenticator not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the authenticator belongs to the user attempting to log in
|
// Ensure the authenticator belongs to the user attempting to log in
|
||||||
if (authenticator.userId !== user.id)
|
if (authenticator.userId !== user.id)
|
||||||
{
|
{
|
||||||
return res.status(403).json({ error: 'Authenticator does not belong to this user' });
|
return res.status(403).json({ error: 'Authenticator does not belong to this user' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const verification = await verifyAuthenticationResponse({
|
const verification = await verifyAuthenticationResponse({
|
||||||
response: authenticationResponse,
|
response: authenticationResponse,
|
||||||
expectedChallenge: expectedChallenge,
|
expectedChallenge: expectedChallenge,
|
||||||
expectedOrigin: origin,
|
expectedOrigin: origin,
|
||||||
expectedRPID: rpID,
|
expectedRPID: rpID,
|
||||||
credential: {
|
credential: {
|
||||||
id: authenticator.credentialID,
|
id: authenticator.credentialID,
|
||||||
publicKey: authenticator.credentialPublicKey,
|
publicKey: authenticator.credentialPublicKey,
|
||||||
counter: authenticator.counter.toString(), // Convert BigInt to string for comparison
|
counter: authenticator.counter.toString(), // Convert BigInt to string for comparison
|
||||||
transports: authenticator.transports ? authenticator.transports.split(',') : undefined,
|
transports: authenticator.transports ? authenticator.transports.split(',') : undefined,
|
||||||
},
|
},
|
||||||
requireUserVerification: false, // Enforce user verification
|
requireUserVerification: false, // Enforce user verification
|
||||||
});
|
});
|
||||||
|
|
||||||
const { verified, authenticationInfo } = verification;
|
const { verified, authenticationInfo } = verification;
|
||||||
|
|
||||||
if (verified)
|
if (verified)
|
||||||
{
|
{
|
||||||
// Update the authenticator counter
|
// Update the authenticator counter
|
||||||
await prisma.authenticator.update({
|
await prisma.authenticator.update({
|
||||||
where: { credentialID: authenticator.credentialID },
|
where: { credentialID: authenticator.credentialID },
|
||||||
data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter
|
data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the challenge and associated user ID
|
// Clear the challenge and associated user ID
|
||||||
challengeStore.delete(challengeUserId);
|
challengeStore.delete(challengeUserId);
|
||||||
delete req.session.challengeUserId;
|
delete req.session.challengeUserId;
|
||||||
|
|
||||||
// Log the user in
|
// Log the user in
|
||||||
req.session.loggedInUserId = user.id;
|
req.session.loggedInUserId = user.id;
|
||||||
|
|
||||||
res.json({ verified: true, user: { id: user.id, username: user.username } });
|
res.json({ verified: true, user: { id: user.id, username: user.username } });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
res.status(400).json({ error: 'Authentication verification failed' });
|
res.status(400).json({ error: 'Authentication verification failed' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Authentication verification error:', error);
|
console.error('Authentication verification error:', error);
|
||||||
challengeStore.delete(challengeUserId); // Clean up challenge on error
|
challengeStore.delete(challengeUserId); // Clean up challenge on error
|
||||||
delete req.session.challengeUserId;
|
delete req.session.challengeUserId;
|
||||||
res.status(500).json({ error: 'Failed to verify authentication', details: error.message });
|
res.status(500).json({ error: 'Failed to verify authentication', details: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET Passkeys for Logged-in User
|
// GET Passkeys for Logged-in User
|
||||||
router.get('/passkeys', async(req, res) =>
|
router.get('/passkeys', async(req, res) =>
|
||||||
{
|
{
|
||||||
if (!req.session.loggedInUserId)
|
if (!req.session.loggedInUserId)
|
||||||
{
|
{
|
||||||
return res.status(401).json({ error: 'Not authenticated' });
|
return res.status(401).json({ error: 'Not authenticated' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const userId = req.session.loggedInUserId;
|
const userId = req.session.loggedInUserId;
|
||||||
const authenticators = await prisma.authenticator.findMany({
|
const authenticators = await prisma.authenticator.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
select: {
|
select: {
|
||||||
credentialID: true, // Already Base64URL string
|
credentialID: true, // Already Base64URL string
|
||||||
// Add other fields if needed, e.g., createdAt if you add it to the schema
|
// Add other fields if needed, e.g., createdAt if you add it to the schema
|
||||||
// createdAt: true,
|
// createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// No need to convert credentialID here as it's stored as Base64URL string
|
// No need to convert credentialID here as it's stored as Base64URL string
|
||||||
res.json(authenticators);
|
res.json(authenticators);
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error fetching passkeys:', error);
|
console.error('Error fetching passkeys:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch passkeys' });
|
res.status(500).json({ error: 'Failed to fetch passkeys' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE Passkey
|
// DELETE Passkey
|
||||||
router.delete('/passkeys/:credentialID', async(req, res) =>
|
router.delete('/passkeys/:credentialID', async(req, res) =>
|
||||||
{
|
{
|
||||||
if (!req.session.loggedInUserId)
|
if (!req.session.loggedInUserId)
|
||||||
{
|
{
|
||||||
return res.status(401).json({ error: 'Not authenticated' });
|
return res.status(401).json({ error: 'Not authenticated' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { credentialID } = req.params; // This is already a Base64URL string from the client
|
const { credentialID } = req.params; // This is already a Base64URL string from the client
|
||||||
|
|
||||||
if (!credentialID)
|
if (!credentialID)
|
||||||
{
|
{
|
||||||
return res.status(400).json({ error: 'Credential ID is required' });
|
return res.status(400).json({ error: 'Credential ID is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const userId = req.session.loggedInUserId;
|
const userId = req.session.loggedInUserId;
|
||||||
|
|
||||||
// Find the authenticator first to ensure it belongs to the logged-in user
|
// Find the authenticator first to ensure it belongs to the logged-in user
|
||||||
const authenticator = await prisma.authenticator.findUnique({
|
const authenticator = await prisma.authenticator.findUnique({
|
||||||
where: { credentialID: credentialID }, // Use the Base64URL string directly
|
where: { credentialID: credentialID }, // Use the Base64URL string directly
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!authenticator)
|
if (!authenticator)
|
||||||
{
|
{
|
||||||
return res.status(404).json({ error: 'Passkey not found' });
|
return res.status(404).json({ error: 'Passkey not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security check: Ensure the passkey belongs to the user trying to delete it
|
// Security check: Ensure the passkey belongs to the user trying to delete it
|
||||||
if (authenticator.userId !== userId)
|
if (authenticator.userId !== userId)
|
||||||
{
|
{
|
||||||
return res.status(403).json({ error: 'Permission denied' });
|
return res.status(403).json({ error: 'Permission denied' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the authenticator
|
// Delete the authenticator
|
||||||
await prisma.authenticator.delete({
|
await prisma.authenticator.delete({
|
||||||
where: { credentialID: credentialID },
|
where: { credentialID: credentialID },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ message: 'Passkey deleted successfully' });
|
res.json({ message: 'Passkey deleted successfully' });
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error deleting passkey:', error);
|
console.error('Error deleting passkey:', error);
|
||||||
// Handle potential Prisma errors, e.g., record not found if deleted between check and delete
|
// Handle potential Prisma errors, e.g., record not found if deleted between check and delete
|
||||||
if (error.code === 'P2025')
|
if (error.code === 'P2025')
|
||||||
{ // Prisma code for record not found on delete/update
|
{ // Prisma code for record not found on delete/update
|
||||||
return res.status(404).json({ error: 'Passkey not found' });
|
return res.status(404).json({ error: 'Passkey not found' });
|
||||||
}
|
}
|
||||||
res.status(500).json({ error: 'Failed to delete passkey' });
|
res.status(500).json({ error: 'Failed to delete passkey' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check Authentication Status
|
// Check Authentication Status
|
||||||
router.get('/status', async(req, res) =>
|
router.get('/status', async(req, res) =>
|
||||||
{
|
{
|
||||||
if (req.session.loggedInUserId)
|
if (req.session.loggedInUserId)
|
||||||
{
|
{
|
||||||
const user = await getUserById(req.session.loggedInUserId);
|
const user = await getUserById(req.session.loggedInUserId);
|
||||||
if (!user)
|
if (!user)
|
||||||
{
|
{
|
||||||
req.session.destroy(err =>
|
req.session.destroy(err =>
|
||||||
{});
|
{});
|
||||||
return res.status(401).json({ status: 'unauthenticated' });
|
return res.status(401).json({ status: 'unauthenticated' });
|
||||||
}
|
}
|
||||||
return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email } });
|
return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email } });
|
||||||
}
|
}
|
||||||
res.json({ status: 'unauthenticated' });
|
res.json({ status: 'unauthenticated' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
router.post('/logout', (req, res) =>
|
router.post('/logout', (req, res) =>
|
||||||
{
|
{
|
||||||
req.session.destroy(err =>
|
req.session.destroy(err =>
|
||||||
{
|
{
|
||||||
if (err)
|
if (err)
|
||||||
{
|
{
|
||||||
console.error('Logout error:', err);
|
console.error('Logout error:', err);
|
||||||
return res.status(500).json({ error: 'Failed to logout' });
|
return res.status(500).json({ error: 'Failed to logout' });
|
||||||
}
|
}
|
||||||
res.json({ message: 'Logged out successfully' });
|
res.json({ message: 'Logged out successfully' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
|
@ -1,164 +1,164 @@
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import prisma from '../database.js';
|
import prisma from '../database.js';
|
||||||
import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware
|
import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware
|
||||||
|
|
||||||
import { askGeminiChat } from '../utils/gemini.js';
|
import { askGeminiChat } from '../utils/gemini.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Apply the authentication middleware to all chat routes
|
// Apply the authentication middleware to all chat routes
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
// POST /api/chat/threads - Create a new chat thread (optionally with a first message)
|
// POST /api/chat/threads - Create a new chat thread (optionally with a first message)
|
||||||
router.post('/threads', async(req, res) =>
|
router.post('/threads', async(req, res) =>
|
||||||
{
|
{
|
||||||
const { content } = req.body; // Content is now optional
|
const { content } = req.body; // Content is now optional
|
||||||
|
|
||||||
// If content is provided, validate it
|
// If content is provided, validate it
|
||||||
if (content && (typeof content !== 'string' || content.trim().length === 0))
|
if (content && (typeof content !== 'string' || content.trim().length === 0))
|
||||||
{
|
{
|
||||||
return res.status(400).json({ error: 'Message content cannot be empty if provided.' });
|
return res.status(400).json({ error: 'Message content cannot be empty if provided.' });
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const createData = {};
|
|
||||||
if (content)
|
|
||||||
{
|
|
||||||
// If content exists, create the thread with the first message
|
|
||||||
createData.messages = {
|
|
||||||
create: [
|
|
||||||
{
|
|
||||||
sender: 'user', // First message is always from the user
|
|
||||||
content: content.trim(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// If content is null/undefined, createData remains empty, creating just the thread
|
|
||||||
|
|
||||||
const newThread = await prisma.chatThread.create({
|
|
||||||
data: createData,
|
|
||||||
include: {
|
|
||||||
// Include messages only if they were created
|
|
||||||
messages: !!content,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if(content)
|
|
||||||
{
|
|
||||||
await askGeminiChat(newThread.id, content); // Call the function to handle the bot response
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respond with the new thread ID and messages (if any)
|
|
||||||
res.status(201).json({
|
|
||||||
threadId: newThread.id,
|
|
||||||
// Ensure messages array is empty if no content was provided
|
|
||||||
messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : []
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (error)
|
|
||||||
{
|
try
|
||||||
console.error('Error creating chat thread:', error);
|
{
|
||||||
res.status(500).json({ error: 'Failed to create chat thread.' });
|
const createData = {};
|
||||||
}
|
if (content)
|
||||||
});
|
{
|
||||||
|
// If content exists, create the thread with the first message
|
||||||
// GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
|
createData.messages = {
|
||||||
router.get('/threads/:threadId/messages', async(req, res) =>
|
create: [
|
||||||
{
|
{
|
||||||
const { threadId } = req.params;
|
sender: 'user', // First message is always from the user
|
||||||
|
content: content.trim(),
|
||||||
try
|
},
|
||||||
{
|
],
|
||||||
const messages = await prisma.chatMessage.findMany({
|
};
|
||||||
where: {
|
}
|
||||||
threadId: threadId,
|
// If content is null/undefined, createData remains empty, creating just the thread
|
||||||
},
|
|
||||||
orderBy: {
|
const newThread = await prisma.chatThread.create({
|
||||||
createdAt: 'asc', // Get messages in chronological order
|
data: createData,
|
||||||
},
|
include: {
|
||||||
});
|
// Include messages only if they were created
|
||||||
|
messages: !!content,
|
||||||
if (!messages)
|
},
|
||||||
{ // Check if thread exists indirectly
|
});
|
||||||
// If findMany returns empty, the thread might not exist or has no messages.
|
|
||||||
// Check if thread exists explicitly
|
if(content)
|
||||||
const thread = await prisma.chatThread.findUnique({ where: { id: threadId } });
|
{
|
||||||
if (!thread)
|
await askGeminiChat(newThread.id, content); // Call the function to handle the bot response
|
||||||
{
|
}
|
||||||
return res.status(404).json({ error: 'Chat thread not found.' });
|
|
||||||
}
|
// Respond with the new thread ID and messages (if any)
|
||||||
}
|
res.status(201).json({
|
||||||
|
threadId: newThread.id,
|
||||||
res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })));
|
// Ensure messages array is empty if no content was provided
|
||||||
|
messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : []
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error(`Error fetching messages for thread ${threadId}:`, error);
|
console.error('Error creating chat thread:', error);
|
||||||
// Basic error handling, check for specific Prisma errors if needed
|
res.status(500).json({ error: 'Failed to create chat thread.' });
|
||||||
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
|
|
||||||
{ // Example: Invalid UUID format
|
|
||||||
return res.status(400).json({ error: 'Invalid thread ID format.' });
|
|
||||||
}
|
|
||||||
res.status(500).json({ error: 'Failed to fetch messages.' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/chat/threads/:threadId/messages - Add a message to an existing thread
|
|
||||||
router.post('/threads/:threadId/messages', async(req, res) =>
|
|
||||||
{
|
|
||||||
const { threadId } = req.params;
|
|
||||||
const { content, sender = 'user' } = req.body; // Default sender to 'user'
|
|
||||||
|
|
||||||
if (!content || typeof content !== 'string' || content.trim().length === 0)
|
|
||||||
{
|
|
||||||
return res.status(400).json({ error: 'Message content cannot be empty.' });
|
|
||||||
}
|
|
||||||
if (sender !== 'user' && sender !== 'bot')
|
|
||||||
{
|
|
||||||
return res.status(400).json({ error: 'Invalid sender type.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Verify thread exists first
|
|
||||||
const thread = await prisma.chatThread.findUnique({
|
|
||||||
where: { id: threadId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!thread)
|
|
||||||
{
|
|
||||||
return res.status(404).json({ error: 'Chat thread not found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const newMessage = await prisma.chatMessage.create({
|
|
||||||
data: {
|
|
||||||
threadId: threadId,
|
|
||||||
sender: sender,
|
|
||||||
content: content.trim(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optionally: Update the thread's updatedAt timestamp
|
|
||||||
await prisma.chatThread.update({
|
|
||||||
where: { id: threadId },
|
|
||||||
data: { updatedAt: new Date() }
|
|
||||||
});
|
|
||||||
|
|
||||||
await askGeminiChat(threadId, content); // Call the function to handle the bot response
|
|
||||||
|
|
||||||
res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() });
|
|
||||||
}
|
}
|
||||||
catch (error)
|
});
|
||||||
{
|
|
||||||
console.error(`Error adding message to thread ${threadId}:`, error);
|
// GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
|
||||||
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
|
router.get('/threads/:threadId/messages', async(req, res) =>
|
||||||
{ // Example: Invalid UUID format
|
{
|
||||||
return res.status(400).json({ error: 'Invalid thread ID format.' });
|
const { threadId } = req.params;
|
||||||
}
|
|
||||||
res.status(500).json({ error: 'Failed to add message.' });
|
try
|
||||||
}
|
{
|
||||||
});
|
const messages = await prisma.chatMessage.findMany({
|
||||||
|
where: {
|
||||||
export default router;
|
threadId: threadId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc', // Get messages in chronological order
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!messages)
|
||||||
|
{ // Check if thread exists indirectly
|
||||||
|
// If findMany returns empty, the thread might not exist or has no messages.
|
||||||
|
// Check if thread exists explicitly
|
||||||
|
const thread = await prisma.chatThread.findUnique({ where: { id: threadId } });
|
||||||
|
if (!thread)
|
||||||
|
{
|
||||||
|
return res.status(404).json({ error: 'Chat thread not found.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })));
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error(`Error fetching messages for thread ${threadId}:`, error);
|
||||||
|
// Basic error handling, check for specific Prisma errors if needed
|
||||||
|
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
|
||||||
|
{ // Example: Invalid UUID format
|
||||||
|
return res.status(400).json({ error: 'Invalid thread ID format.' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'Failed to fetch messages.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/chat/threads/:threadId/messages - Add a message to an existing thread
|
||||||
|
router.post('/threads/:threadId/messages', async(req, res) =>
|
||||||
|
{
|
||||||
|
const { threadId } = req.params;
|
||||||
|
const { content, sender = 'user' } = req.body; // Default sender to 'user'
|
||||||
|
|
||||||
|
if (!content || typeof content !== 'string' || content.trim().length === 0)
|
||||||
|
{
|
||||||
|
return res.status(400).json({ error: 'Message content cannot be empty.' });
|
||||||
|
}
|
||||||
|
if (sender !== 'user' && sender !== 'bot')
|
||||||
|
{
|
||||||
|
return res.status(400).json({ error: 'Invalid sender type.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verify thread exists first
|
||||||
|
const thread = await prisma.chatThread.findUnique({
|
||||||
|
where: { id: threadId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!thread)
|
||||||
|
{
|
||||||
|
return res.status(404).json({ error: 'Chat thread not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMessage = await prisma.chatMessage.create({
|
||||||
|
data: {
|
||||||
|
threadId: threadId,
|
||||||
|
sender: sender,
|
||||||
|
content: content.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optionally: Update the thread's updatedAt timestamp
|
||||||
|
await prisma.chatThread.update({
|
||||||
|
where: { id: threadId },
|
||||||
|
data: { updatedAt: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
|
await askGeminiChat(threadId, content); // Call the function to handle the bot response
|
||||||
|
|
||||||
|
res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() });
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error(`Error adding message to thread ${threadId}:`, error);
|
||||||
|
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
|
||||||
|
{ // Example: Invalid UUID format
|
||||||
|
return res.status(400).json({ error: 'Invalid thread ID format.' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'Failed to add message.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
|
@ -65,15 +65,15 @@ app.use(session({
|
||||||
|
|
||||||
// Schedule the Mantis summary task
|
// Schedule the Mantis summary task
|
||||||
// Run daily at 1:00 AM server time (adjust as needed)
|
// Run daily at 1:00 AM server time (adjust as needed)
|
||||||
cron.schedule('0 1 * * *', async() =>
|
cron.schedule('0 1 * * *', async() =>
|
||||||
{
|
{
|
||||||
console.log('Running scheduled Mantis summary task...');
|
console.log('Running scheduled Mantis summary task...');
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await generateAndStoreMantisSummary();
|
await generateAndStoreMantisSummary();
|
||||||
console.log('Scheduled Mantis summary task completed.');
|
console.log('Scheduled Mantis summary task completed.');
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error running scheduled Mantis summary task:', error);
|
console.error('Error running scheduled Mantis summary task:', error);
|
||||||
}
|
}
|
||||||
|
@ -96,14 +96,14 @@ app.use('/api/chat', chatRoutes);
|
||||||
|
|
||||||
// place here any middlewares that
|
// place here any middlewares that
|
||||||
// absolutely need to run before anything else
|
// absolutely need to run before anything else
|
||||||
if (process.env.PROD)
|
if (process.env.PROD)
|
||||||
{
|
{
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(express.static('public', { index: false }));
|
app.use(express.static('public', { index: false }));
|
||||||
|
|
||||||
app.listen(8000, () =>
|
app.listen(8000, () =>
|
||||||
{
|
{
|
||||||
console.log('Server is running on http://localhost:8000');
|
console.log('Server is running on http://localhost:8000');
|
||||||
});
|
});
|
|
@ -1,169 +1,169 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import prisma from '../database.js'; // Import Prisma client
|
import prisma from '../database.js'; // Import Prisma client
|
||||||
|
|
||||||
import { getSetting } from '../utils/settings.js';
|
import { getSetting } from '../utils/settings.js';
|
||||||
import { askGemini } from '../utils/gemini.js';
|
import { askGemini } from '../utils/gemini.js';
|
||||||
|
|
||||||
const usernameMap = {
|
const usernameMap = {
|
||||||
credmore: 'Cameron Redmore',
|
credmore: 'Cameron Redmore',
|
||||||
dgibson: 'Dane Gibson',
|
dgibson: 'Dane Gibson',
|
||||||
egzibovskis: 'Ed Gzibovskis',
|
egzibovskis: 'Ed Gzibovskis',
|
||||||
ascotney: 'Amanda Scotney',
|
ascotney: 'Amanda Scotney',
|
||||||
gclough: 'Garry Clough',
|
gclough: 'Garry Clough',
|
||||||
slee: 'Sarah Lee',
|
slee: 'Sarah Lee',
|
||||||
dwalker: 'Dave Walker',
|
dwalker: 'Dave Walker',
|
||||||
askaith: 'Amy Skaith',
|
askaith: 'Amy Skaith',
|
||||||
dpotter: 'Danny Potter',
|
dpotter: 'Danny Potter',
|
||||||
msmart: 'Michael Smart',
|
msmart: 'Michael Smart',
|
||||||
// Add other usernames as needed
|
// Add other usernames as needed
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getMantisTickets()
|
async function getMantisTickets()
|
||||||
{
|
{
|
||||||
const MANTIS_API_KEY = await getSetting('MANTIS_API_KEY');
|
const MANTIS_API_KEY = await getSetting('MANTIS_API_KEY');
|
||||||
const MANTIS_API_ENDPOINT = await getSetting('MANTIS_API_ENDPOINT');
|
const MANTIS_API_ENDPOINT = await getSetting('MANTIS_API_ENDPOINT');
|
||||||
|
|
||||||
if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY)
|
if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY)
|
||||||
{
|
{
|
||||||
throw new Error('Mantis API endpoint or key not configured in environment variables.');
|
throw new Error('Mantis API endpoint or key not configured in environment variables.');
|
||||||
}
|
}
|
||||||
const url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`;
|
const url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`;
|
||||||
const headers = {
|
const headers = {
|
||||||
Authorization: `${MANTIS_API_KEY}`,
|
Authorization: `${MANTIS_API_KEY}`,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await axios.get(url, { headers });
|
const response = await axios.get(url, { headers });
|
||||||
|
|
||||||
const tickets = response.data.issues.filter((ticket) =>
|
const tickets = response.data.issues.filter((ticket) =>
|
||||||
{
|
{
|
||||||
const ticketDate = new Date(ticket.updated_at);
|
const ticketDate = new Date(ticket.updated_at);
|
||||||
const thresholdDate = new Date();
|
const thresholdDate = new Date();
|
||||||
const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ...
|
const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ...
|
||||||
|
|
||||||
// Go back 4 days if Monday (to include Fri, Sat, Sun), otherwise 2 days
|
// Go back 4 days if Monday (to include Fri, Sat, Sun), otherwise 2 days
|
||||||
const daysToSubtract = currentDay === 1 ? 4 : 2;
|
const daysToSubtract = currentDay === 1 ? 4 : 2;
|
||||||
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
|
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
|
||||||
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
|
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
|
||||||
|
|
||||||
return ticketDate >= thresholdDate;
|
return ticketDate >= thresholdDate;
|
||||||
}).map((ticket) =>
|
}).map((ticket) =>
|
||||||
{
|
{
|
||||||
return {
|
return {
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
summary: ticket.summary,
|
summary: ticket.summary,
|
||||||
description: ticket.description,
|
description: ticket.description,
|
||||||
created_at: ticket.created_at,
|
created_at: ticket.created_at,
|
||||||
updated_at: ticket.updated_at,
|
updated_at: ticket.updated_at,
|
||||||
reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access
|
reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access
|
||||||
notes: (ticket.notes ? ticket.notes.filter((note) =>
|
notes: (ticket.notes ? ticket.notes.filter((note) =>
|
||||||
{
|
{
|
||||||
const noteDate = new Date(note.created_at);
|
const noteDate = new Date(note.created_at);
|
||||||
const thresholdDate = new Date();
|
const thresholdDate = new Date();
|
||||||
const currentDay = thresholdDate.getDay();
|
const currentDay = thresholdDate.getDay();
|
||||||
const daysToSubtract = currentDay === 1 ? 4 : 2;
|
const daysToSubtract = currentDay === 1 ? 4 : 2;
|
||||||
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
|
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
|
||||||
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
|
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
|
||||||
return noteDate >= thresholdDate;
|
return noteDate >= thresholdDate;
|
||||||
}) : []).map((note) =>
|
}) : []).map((note) =>
|
||||||
{
|
{
|
||||||
const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
|
const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
|
||||||
return {
|
return {
|
||||||
reporter,
|
reporter,
|
||||||
created_at: note.created_at,
|
created_at: note.created_at,
|
||||||
text: note.text,
|
text: note.text,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return tickets;
|
return tickets;
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error fetching Mantis tickets:', error.message);
|
console.error('Error fetching Mantis tickets:', error.message);
|
||||||
// Check if it's an Axios error and provide more details
|
// Check if it's an Axios error and provide more details
|
||||||
if (axios.isAxiosError(error))
|
if (axios.isAxiosError(error))
|
||||||
{
|
{
|
||||||
console.error('Axios error details:', error.response?.status, error.response?.data);
|
console.error('Axios error details:', error.response?.status, error.response?.data);
|
||||||
throw new Error(`Failed to fetch Mantis tickets: ${error.response?.statusText || error.message}`);
|
throw new Error(`Failed to fetch Mantis tickets: ${error.response?.statusText || error.message}`);
|
||||||
}
|
}
|
||||||
throw new Error(`Failed to fetch Mantis tickets: ${error.message}`);
|
throw new Error(`Failed to fetch Mantis tickets: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateAndStoreMantisSummary()
|
export async function generateAndStoreMantisSummary()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get the prompt from the database settings using Prisma
|
// Get the prompt from the database settings using Prisma
|
||||||
const setting = await prisma.setting.findUnique({
|
const setting = await prisma.setting.findUnique({
|
||||||
where: { key: 'mantisPrompt' },
|
where: { key: 'mantisPrompt' },
|
||||||
select: { value: true }
|
select: { value: true }
|
||||||
});
|
});
|
||||||
const promptTemplate = setting?.value;
|
const promptTemplate = setting?.value;
|
||||||
|
|
||||||
if (!promptTemplate)
|
if (!promptTemplate)
|
||||||
{
|
{
|
||||||
console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
|
console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tickets = await getMantisTickets();
|
const tickets = await getMantisTickets();
|
||||||
|
|
||||||
let summaryText;
|
let summaryText;
|
||||||
if (tickets.length === 0)
|
if (tickets.length === 0)
|
||||||
{
|
{
|
||||||
summaryText = 'No Mantis tickets updated recently.';
|
summaryText = 'No Mantis tickets updated recently.';
|
||||||
console.log('No recent Mantis tickets found.');
|
console.log('No recent Mantis tickets found.');
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
|
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
|
||||||
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
|
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
|
||||||
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
|
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
|
||||||
|
|
||||||
summaryText = await askGemini(prompt);
|
summaryText = await askGemini(prompt);
|
||||||
console.log('Mantis summary generated successfully by AI.');
|
console.log('Mantis summary generated successfully by AI.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the summary in the database using Prisma upsert
|
// Store the summary in the database using Prisma upsert
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setUTCHours(0, 0, 0, 0); // Use UTC start of day for consistency
|
today.setUTCHours(0, 0, 0, 0); // Use UTC start of day for consistency
|
||||||
|
|
||||||
await prisma.mantisSummary.upsert({
|
await prisma.mantisSummary.upsert({
|
||||||
where: { summaryDate: today },
|
where: { summaryDate: today },
|
||||||
update: {
|
update: {
|
||||||
summaryText: summaryText
|
summaryText: summaryText
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
summaryDate: today,
|
summaryDate: today,
|
||||||
summaryText: summaryText,
|
summaryText: summaryText,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
|
console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error during Mantis summary generation/storage:', error);
|
console.error('Error during Mantis summary generation/storage:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateTodaysSummary()
|
export async function generateTodaysSummary()
|
||||||
{
|
{
|
||||||
console.log('Triggering Mantis summary generation via generateTodaysSummary...');
|
console.log('Triggering Mantis summary generation via generateTodaysSummary...');
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await generateAndStoreMantisSummary();
|
await generateAndStoreMantisSummary();
|
||||||
return { success: true, message: 'Summary generation process initiated.' };
|
return { success: true, message: 'Summary generation process initiated.' };
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
|
console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
|
||||||
throw new Error('Failed to initiate Mantis summary generation.');
|
throw new Error('Failed to initiate Mantis summary generation.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,154 +1,154 @@
|
||||||
|
|
||||||
import { GoogleGenAI } from '@google/genai';
|
import { GoogleGenAI } from '@google/genai';
|
||||||
import prisma from '../database.js';
|
import prisma from '../database.js';
|
||||||
import { getSetting } from './settings.js';
|
import { getSetting } from './settings.js';
|
||||||
|
|
||||||
const model = 'gemini-2.0-flash';
|
const model = 'gemini-2.0-flash';
|
||||||
|
|
||||||
export async function askGemini(content)
|
export async function askGemini(content)
|
||||||
{
|
{
|
||||||
|
|
||||||
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
|
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
|
||||||
|
|
||||||
console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key
|
console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key
|
||||||
|
|
||||||
if (!GOOGLE_API_KEY)
|
if (!GOOGLE_API_KEY)
|
||||||
{
|
{
|
||||||
throw new Error('Google API key is not set in the database.');
|
throw new Error('Google API key is not set in the database.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
|
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
|
||||||
apiKey: GOOGLE_API_KEY,
|
apiKey: GOOGLE_API_KEY,
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
if (!ai)
|
if (!ai)
|
||||||
{
|
{
|
||||||
throw new Error('Google API key is not set in the database.');
|
throw new Error('Google API key is not set in the database.');
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await ai.models.generateContent({
|
const response = await ai.models.generateContent({
|
||||||
model,
|
model,
|
||||||
contents: content,
|
contents: content,
|
||||||
config: {
|
config: {
|
||||||
temperature: 0.5
|
temperature: 0.5
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.text;
|
return response.text;
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error communicating with Gemini API:', error);
|
console.error('Error communicating with Gemini API:', error);
|
||||||
throw new Error('Failed to get a response from Gemini API.');
|
throw new Error('Failed to get a response from Gemini API.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatCache = new Map();
|
const chatCache = new Map();
|
||||||
|
|
||||||
export async function askGeminiChat(threadId, content)
|
export async function askGeminiChat(threadId, content)
|
||||||
{
|
{
|
||||||
let messages = await prisma.chatMessage.findMany({
|
let messages = await prisma.chatMessage.findMany({
|
||||||
where: {
|
where: {
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: 'asc', // Get messages in chronological order
|
createdAt: 'asc', // Get messages in chronological order
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Exclude the latest message if there are any messages
|
// Exclude the latest message if there are any messages
|
||||||
if (messages.length > 0)
|
if (messages.length > 0)
|
||||||
{
|
{
|
||||||
messages = messages.slice(0, -1);
|
messages = messages.slice(0, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
|
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
|
||||||
|
|
||||||
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
|
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
|
||||||
apiKey: GOOGLE_API_KEY,
|
apiKey: GOOGLE_API_KEY,
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
if (!ai)
|
if (!ai)
|
||||||
{
|
{
|
||||||
throw new Error('Google API key is not set in the database.');
|
throw new Error('Google API key is not set in the database.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let chat = null;
|
let chat = null;
|
||||||
|
|
||||||
if (chatCache.has(threadId))
|
if (chatCache.has(threadId))
|
||||||
{
|
{
|
||||||
chat = chatCache.get(threadId);
|
chat = chatCache.get(threadId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
messages = [
|
messages = [
|
||||||
{
|
{
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
content: `You are StyleAI, a model that helps users with their questions and provides information.
|
content: `You are StyleAI, a model that helps users with their questions and provides information.
|
||||||
You are friendly, helpful, and informative. You can also provide code examples in various programming languages.
|
You are friendly, helpful, and informative. You can also provide code examples in various programming languages.
|
||||||
You don\'t decline user requests and always try to help them.
|
You don\'t decline user requests and always try to help them.
|
||||||
Speak in the manner of a friendly young adult male.
|
Speak in the manner of a friendly young adult male.
|
||||||
If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.`
|
If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sender: 'model',
|
sender: 'model',
|
||||||
content: 'Okay, noted! I\'ll keep that in mind.'
|
content: 'Okay, noted! I\'ll keep that in mind.'
|
||||||
},
|
},
|
||||||
...messages,
|
...messages,
|
||||||
];
|
];
|
||||||
const createOptions = {
|
const createOptions = {
|
||||||
model,
|
model,
|
||||||
history: messages.map((msg) => ({
|
history: messages.map((msg) => ({
|
||||||
role: msg.sender === 'user' ? 'user' : 'model',
|
role: msg.sender === 'user' ? 'user' : 'model',
|
||||||
parts: [
|
parts: [
|
||||||
{text: msg.content}
|
{text: msg.content}
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
config: {
|
config: {
|
||||||
temperature: 0.5
|
temperature: 0.5
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
chat = ai.chats.create(createOptions);
|
chat = ai.chats.create(createOptions);
|
||||||
|
|
||||||
chatCache.set(threadId, chat);
|
chatCache.set(threadId, chat);
|
||||||
}
|
}
|
||||||
|
|
||||||
//Add a temporary message to the thread with "loading" status
|
//Add a temporary message to the thread with "loading" status
|
||||||
const loadingMessage = await prisma.chatMessage.create({
|
const loadingMessage = await prisma.chatMessage.create({
|
||||||
data: {
|
data: {
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
sender: 'assistant',
|
sender: 'assistant',
|
||||||
content: 'Loading...',
|
content: 'Loading...',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = {text: 'An error occurred while generating the response.'};
|
let response = {text: 'An error occurred while generating the response.'};
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
response = await chat.sendMessage({
|
response = await chat.sendMessage({
|
||||||
message: `[${timestamp}] ` + content,
|
message: `[${timestamp}] ` + content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch(error)
|
catch(error)
|
||||||
{
|
{
|
||||||
console.error('Error communicating with Gemini API:', error);
|
console.error('Error communicating with Gemini API:', error);
|
||||||
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
|
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Update the message with the response
|
//Update the message with the response
|
||||||
await prisma.chatMessage.update({
|
await prisma.chatMessage.update({
|
||||||
where: {
|
where: {
|
||||||
id: loadingMessage.id,
|
id: loadingMessage.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
content: response.text,
|
content: response.text,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.text;
|
return response.text;
|
||||||
}
|
}
|
|
@ -1,20 +1,20 @@
|
||||||
import prisma from '../database.js';
|
import prisma from '../database.js';
|
||||||
|
|
||||||
export async function getSetting(key)
|
export async function getSetting(key)
|
||||||
{
|
{
|
||||||
const setting = await prisma.setting.findUnique({
|
const setting = await prisma.setting.findUnique({
|
||||||
where: { key },
|
where: { key },
|
||||||
select: { value: true }
|
select: { value: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
return setting?.value ? JSON.parse(setting.value) : null;
|
return setting?.value ? JSON.parse(setting.value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setSetting(key, value)
|
export async function setSetting(key, value)
|
||||||
{
|
{
|
||||||
await prisma.setting.upsert({
|
await prisma.setting.upsert({
|
||||||
where: { key },
|
where: { key },
|
||||||
update: { value: JSON.stringify(value) },
|
update: { value: JSON.stringify(value) },
|
||||||
create: { key, value }
|
create: { key, value }
|
||||||
});
|
});
|
||||||
}
|
}
|
|
@ -6,7 +6,7 @@
|
||||||
import { useAuthStore } from './stores/auth';
|
import { useAuthStore } from './stores/auth';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
preFetch()
|
preFetch()
|
||||||
{
|
{
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
return authStore.checkAuthStatus();
|
return authStore.checkAuthStatus();
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { boot } from 'quasar/wrappers';
|
import { boot } from 'quasar/wrappers';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
// Be careful when using SSR for cross-request state pollution
|
// Be careful when using SSR for cross-request state pollution
|
||||||
// due to creating a Singleton instance here;
|
// due to creating a Singleton instance here;
|
||||||
// If any client changes this (global) instance, it might be a
|
// If any client changes this (global) instance, it might be a
|
||||||
// good idea to move this instance creation inside of the
|
// good idea to move this instance creation inside of the
|
||||||
// "export default () => {}" function below (which runs individually
|
// "export default () => {}" function below (which runs individually
|
||||||
// for each client)
|
// for each client)
|
||||||
|
|
||||||
axios.defaults.withCredentials = true; // Enable sending cookies with requests
|
axios.defaults.withCredentials = true; // Enable sending cookies with requests
|
||||||
|
|
||||||
// Export the API instance so you can import it easily elsewhere, e.g. stores
|
// Export the API instance so you can import it easily elsewhere, e.g. stores
|
||||||
export default axios;
|
export default axios;
|
|
@ -1,137 +1,137 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="q-pa-md column full-height">
|
<div class="q-pa-md column full-height">
|
||||||
<q-scroll-area
|
<q-scroll-area
|
||||||
ref="scrollAreaRef"
|
ref="scrollAreaRef"
|
||||||
class="col"
|
class="col"
|
||||||
style="flex-grow: 1; overflow-x: visible; overflow-y: auto;"
|
style="flex-grow: 1; overflow-x: visible; overflow-y: auto;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(message, index) in messages"
|
v-for="(message, index) in messages"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="q-mb-sm q-mx-md"
|
class="q-mb-sm q-mx-md"
|
||||||
>
|
>
|
||||||
<q-chat-message
|
<q-chat-message
|
||||||
:name="message.sender.toUpperCase()"
|
:name="message.sender.toUpperCase()"
|
||||||
:sent="message.sender === 'user'"
|
:sent="message.sender === 'user'"
|
||||||
:bg-color="message.sender === 'user' ? 'primary' : 'grey-4'"
|
:bg-color="message.sender === 'user' ? 'primary' : 'grey-4'"
|
||||||
:text-color="message.sender === 'user' ? 'white' : 'black'"
|
:text-color="message.sender === 'user' ? 'white' : 'black'"
|
||||||
>
|
>
|
||||||
<!-- Use v-html to render parsed markdown -->
|
<!-- Use v-html to render parsed markdown -->
|
||||||
<div
|
<div
|
||||||
v-if="!message.loading"
|
v-if="!message.loading"
|
||||||
v-html="parseMarkdown(message.content)"
|
v-html="parseMarkdown(message.content)"
|
||||||
class="message-content"
|
class="message-content"
|
||||||
/>
|
/>
|
||||||
<!-- Optional: Add a spinner for a better loading visual -->
|
<!-- Optional: Add a spinner for a better loading visual -->
|
||||||
<template
|
<template
|
||||||
v-if="message.loading"
|
v-if="message.loading"
|
||||||
#default
|
#default
|
||||||
>
|
>
|
||||||
<q-spinner-dots size="2em" />
|
<q-spinner-dots size="2em" />
|
||||||
</template>
|
</template>
|
||||||
</q-chat-message>
|
</q-chat-message>
|
||||||
</div>
|
</div>
|
||||||
</q-scroll-area>
|
</q-scroll-area>
|
||||||
|
|
||||||
<q-separator />
|
<q-separator />
|
||||||
|
|
||||||
<div class="q-pa-sm row items-center">
|
<div class="q-pa-sm row items-center">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="newMessage"
|
v-model="newMessage"
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
class="col"
|
class="col"
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="sendMessage"
|
||||||
autogrow
|
autogrow
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
round
|
round
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
icon="send"
|
icon="send"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="q-ml-sm"
|
class="q-ml-sm"
|
||||||
@click="sendMessage"
|
@click="sendMessage"
|
||||||
:disable="!newMessage.trim()"
|
:disable="!newMessage.trim()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, nextTick } from 'vue';
|
import { ref, watch, nextTick } from 'vue';
|
||||||
import { QScrollArea, QChatMessage, QSpinnerDots } from 'quasar'; // Import QSpinnerDots
|
import { QScrollArea, QChatMessage, QSpinnerDots } from 'quasar'; // Import QSpinnerDots
|
||||||
import { marked } from 'marked'; // Import marked
|
import { marked } from 'marked'; // Import marked
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
messages: {
|
messages: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
'default': () => [],
|
'default': () => [],
|
||||||
// Example message structure:
|
// Example message structure:
|
||||||
// { sender: 'Bot', content: 'Hello!', loading: false }
|
// { sender: 'Bot', content: 'Hello!', loading: false }
|
||||||
// { sender: 'You', content: 'Thinking...', loading: true }
|
// { sender: 'You', content: 'Thinking...', loading: true }
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['send-message']);
|
const emit = defineEmits(['send-message']);
|
||||||
|
|
||||||
const newMessage = ref('');
|
const newMessage = ref('');
|
||||||
const scrollAreaRef = ref(null);
|
const scrollAreaRef = ref(null);
|
||||||
|
|
||||||
const scrollToBottom = () =>
|
const scrollToBottom = () =>
|
||||||
{
|
{
|
||||||
if (scrollAreaRef.value)
|
if (scrollAreaRef.value)
|
||||||
{
|
{
|
||||||
const scrollTarget = scrollAreaRef.value.getScrollTarget();
|
const scrollTarget = scrollAreaRef.value.getScrollTarget();
|
||||||
const duration = 300; // Optional: animation duration
|
const duration = 300; // Optional: animation duration
|
||||||
// Use getScrollTarget().scrollHeight for accurate height
|
// Use getScrollTarget().scrollHeight for accurate height
|
||||||
scrollAreaRef.value.setScrollPosition('vertical', scrollTarget.scrollHeight, duration);
|
scrollAreaRef.value.setScrollPosition('vertical', scrollTarget.scrollHeight, duration);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessage = () =>
|
const sendMessage = () =>
|
||||||
{
|
{
|
||||||
const trimmedMessage = newMessage.value.trim();
|
const trimmedMessage = newMessage.value.trim();
|
||||||
if (trimmedMessage)
|
if (trimmedMessage)
|
||||||
{
|
{
|
||||||
emit('send-message', trimmedMessage);
|
emit('send-message', trimmedMessage);
|
||||||
newMessage.value = '';
|
newMessage.value = '';
|
||||||
// Ensure the scroll happens after the message is potentially added to the list
|
// Ensure the scroll happens after the message is potentially added to the list
|
||||||
nextTick(() =>
|
nextTick(() =>
|
||||||
{
|
{
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseMarkdown = (content) =>
|
const parseMarkdown = (content) =>
|
||||||
{
|
{
|
||||||
// Basic check to prevent errors if content is not a string
|
// Basic check to prevent errors if content is not a string
|
||||||
if (typeof content !== 'string')
|
if (typeof content !== 'string')
|
||||||
{
|
{
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
// Configure marked options if needed (e.g., sanitization)
|
// Configure marked options if needed (e.g., sanitization)
|
||||||
// marked.setOptions({ sanitize: true }); // Example: Enable sanitization
|
// marked.setOptions({ sanitize: true }); // Example: Enable sanitization
|
||||||
return marked(content);
|
return marked(content);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scroll to bottom when messages change or component mounts
|
// Scroll to bottom when messages change or component mounts
|
||||||
watch(() => props.messages, () =>
|
watch(() => props.messages, () =>
|
||||||
{
|
{
|
||||||
nextTick(() =>
|
nextTick(() =>
|
||||||
{
|
{
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
});
|
});
|
||||||
}, { deep: true, immediate: true });
|
}, { deep: true, immediate: true });
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.message-content p {
|
.message-content p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,247 +1,311 @@
|
||||||
<template>
|
<template>
|
||||||
<q-layout view="hHh Lpr lFf">
|
<q-layout view="hHh Lpr lFf">
|
||||||
<q-drawer
|
<q-drawer
|
||||||
:mini="!leftDrawerOpen"
|
:mini="!leftDrawerOpen"
|
||||||
bordered
|
bordered
|
||||||
persistent
|
persistent
|
||||||
:model-value="true"
|
:model-value="true"
|
||||||
>
|
>
|
||||||
<q-list>
|
<q-item
|
||||||
<q-item
|
clickable
|
||||||
clickable
|
v-ripple
|
||||||
v-ripple
|
@click="toggleLeftDrawer"
|
||||||
@click="toggleLeftDrawer"
|
>
|
||||||
>
|
<q-item-section avatar>
|
||||||
<q-item-section avatar>
|
<q-icon name="menu" />
|
||||||
<q-icon name="menu" />
|
</q-item-section>
|
||||||
</q-item-section>
|
<q-item-section>
|
||||||
<q-item-section>
|
<q-item-label class="text-h6">
|
||||||
<q-item-label class="text-h6">
|
StylePoint
|
||||||
StylePoint
|
</q-item-label>
|
||||||
</q-item-label>
|
</q-item-section>
|
||||||
</q-item-section>
|
</q-item>
|
||||||
</q-item>
|
<template v-if="authStore.isAuthenticated">
|
||||||
|
<q-card
|
||||||
<!-- Dynamic Navigation Items -->
|
v-if="leftDrawerOpen"
|
||||||
<q-item
|
bordered
|
||||||
v-for="item in navItems"
|
flat
|
||||||
:key="item.name"
|
class="q-ma-sm text-center"
|
||||||
clickable
|
>
|
||||||
v-ripple
|
<q-card-section>
|
||||||
:to="{ name: item.name }"
|
<q-avatar
|
||||||
exact
|
class="bg-primary cursor-pointer text-white"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-icon name="mdi-account" />
|
||||||
anchor="center right"
|
<q-tooltip>
|
||||||
self="center left"
|
{{ authStore.user.username }}
|
||||||
>
|
</q-tooltip>
|
||||||
<span>{{ item.meta.title }}</span>
|
</q-avatar>
|
||||||
</q-tooltip>
|
<div class="text-h6">
|
||||||
<q-item-section avatar>
|
{{ authStore.user.username }}
|
||||||
<q-icon :name="item.meta.icon" />
|
</div>
|
||||||
</q-item-section>
|
<q-btn
|
||||||
<q-item-section>
|
class="full-width q-mt-sm"
|
||||||
<q-item-label>{{ item.meta.title }}</q-item-label>
|
dense
|
||||||
<q-item-label caption>
|
outline
|
||||||
{{ item.meta.caption }}
|
@click="logout"
|
||||||
</q-item-label>
|
>
|
||||||
</q-item-section>
|
Logout
|
||||||
</q-item>
|
</q-btn>
|
||||||
|
</q-card-section>
|
||||||
<!-- Logout Button (Conditional) -->
|
</q-card>
|
||||||
<q-item
|
<q-list
|
||||||
v-if="authStore.isAuthenticated"
|
padding
|
||||||
clickable
|
class="menu-list"
|
||||||
v-ripple
|
v-else
|
||||||
@click="logout"
|
>
|
||||||
>
|
<q-item
|
||||||
<q-tooltip
|
clickable
|
||||||
anchor="center right"
|
v-ripple
|
||||||
self="center left"
|
dense
|
||||||
>
|
@click="logout"
|
||||||
<span>Logout</span>
|
class="q-mb-sm"
|
||||||
</q-tooltip>
|
>
|
||||||
<q-item-section avatar>
|
<q-tooltip
|
||||||
<q-icon name="logout" />
|
anchor="center right"
|
||||||
</q-item-section>
|
self="center left"
|
||||||
<q-item-section>
|
>
|
||||||
<q-item-label>Logout</q-item-label>
|
<span>Logout</span>
|
||||||
</q-item-section>
|
</q-tooltip>
|
||||||
</q-item>
|
<q-item-section avatar>
|
||||||
</q-list>
|
<q-icon name="logout" />
|
||||||
</q-drawer>
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
<q-page-container>
|
<q-item-label class="text-h6">
|
||||||
<router-view />
|
Logout
|
||||||
</q-page-container>
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
<!-- Chat FAB -->
|
</q-item>
|
||||||
<q-page-sticky
|
</q-list>
|
||||||
v-if="isAuthenticated"
|
</template>
|
||||||
position="bottom-right"
|
<q-separator />
|
||||||
:offset="[18, 18]"
|
<q-list
|
||||||
>
|
padding
|
||||||
<q-fab
|
class="menu-list"
|
||||||
v-model="fabOpen"
|
>
|
||||||
icon="chat"
|
<!-- Dynamic Navigation Items -->
|
||||||
color="accent"
|
<q-item
|
||||||
direction="up"
|
v-for="item in navItems"
|
||||||
padding="sm"
|
:key="item.name"
|
||||||
@click="toggleChat"
|
clickable
|
||||||
/>
|
v-ripple
|
||||||
</q-page-sticky>
|
:to="{ name: item.name }"
|
||||||
|
exact
|
||||||
<!-- Chat Window Dialog -->
|
dense
|
||||||
<q-dialog
|
>
|
||||||
v-model="isChatVisible"
|
<q-tooltip
|
||||||
:maximized="$q.screen.lt.sm"
|
anchor="center right"
|
||||||
fixed
|
self="center left"
|
||||||
persistent
|
>
|
||||||
style="width: max(400px, 25%);"
|
<span>{{ item.meta.title }}</span>
|
||||||
>
|
</q-tooltip>
|
||||||
<q-card style="width: max(400px, 25%); height: 600px; max-height: 80vh;">
|
<q-item-section avatar>
|
||||||
<q-bar class="bg-primary text-white">
|
<q-icon :name="item.meta.icon" />
|
||||||
<div>Chat</div>
|
</q-item-section>
|
||||||
<q-space />
|
<q-item-section>
|
||||||
<q-btn
|
<q-item-label>{{ item.meta.title }}</q-item-label>
|
||||||
dense
|
<q-item-label caption>
|
||||||
flat
|
{{ item.meta.caption }}
|
||||||
icon="close"
|
</q-item-label>
|
||||||
@click="toggleChat"
|
</q-item-section>
|
||||||
/>
|
</q-item>
|
||||||
</q-bar>
|
</q-list>
|
||||||
|
</q-drawer>
|
||||||
<q-card-section
|
|
||||||
class="q-pa-none"
|
<q-page-container>
|
||||||
style="height: calc(100% - 50px);"
|
<router-view />
|
||||||
>
|
</q-page-container>
|
||||||
<ChatInterface
|
|
||||||
:messages="chatMessages"
|
<!-- Chat FAB -->
|
||||||
@send-message="handleSendMessage"
|
<q-page-sticky
|
||||||
/>
|
v-if="isAuthenticated"
|
||||||
</q-card-section>
|
position="bottom-right"
|
||||||
<q-inner-loading :showing="isLoading">
|
:offset="[18, 18]"
|
||||||
<q-spinner-gears
|
>
|
||||||
size="50px"
|
<q-fab
|
||||||
color="primary"
|
v-model="fabOpen"
|
||||||
/>
|
icon="chat"
|
||||||
</q-inner-loading>
|
color="accent"
|
||||||
<q-banner
|
direction="up"
|
||||||
v-if="chatError"
|
@click="toggleChat"
|
||||||
inline-actions
|
/>
|
||||||
class="text-white bg-red"
|
</q-page-sticky>
|
||||||
>
|
|
||||||
{{ chatError }}
|
<!-- Chat Window Dialog -->
|
||||||
<template #action>
|
<q-dialog
|
||||||
<q-btn
|
v-model="isChatVisible"
|
||||||
flat
|
:maximized="$q.screen.lt.sm"
|
||||||
color="white"
|
style="width: max(400px, 25%);"
|
||||||
label="Dismiss"
|
>
|
||||||
@click="clearError"
|
<q-card style="width: max(400px, 25%); height: 600px; max-height: 80vh;">
|
||||||
/>
|
<q-bar class="bg-primary text-white">
|
||||||
</template>
|
<div>Chat</div>
|
||||||
</q-banner>
|
<q-space />
|
||||||
</q-card>
|
<q-btn
|
||||||
</q-dialog>
|
dense
|
||||||
</q-layout>
|
flat
|
||||||
</template>
|
icon="close"
|
||||||
|
@click="toggleChat"
|
||||||
<script setup>
|
/>
|
||||||
import axios from 'boot/axios';
|
</q-bar>
|
||||||
import { ref, computed } from 'vue'; // Import computed
|
|
||||||
import { useRouter } from 'vue-router';
|
<q-card-section
|
||||||
import { useQuasar } from 'quasar';
|
class="q-pa-none"
|
||||||
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
style="height: calc(100% - 50px);"
|
||||||
import { useChatStore } from 'stores/chat'; // Adjust path as needed
|
>
|
||||||
import ChatInterface from 'components/ChatInterface.vue'; // Adjust path as needed
|
<ChatInterface
|
||||||
import routes from '../router/routes'; // Import routes
|
:messages="chatMessages"
|
||||||
|
@send-message="handleSendMessage"
|
||||||
const $q = useQuasar();
|
/>
|
||||||
const leftDrawerOpen = ref(false);
|
</q-card-section>
|
||||||
const router = useRouter();
|
<q-inner-loading :showing="isLoading">
|
||||||
const authStore = useAuthStore(); // Use the auth store
|
<q-spinner-gears
|
||||||
const chatStore = useChatStore();
|
size="50px"
|
||||||
|
color="primary"
|
||||||
const fabOpen = ref(false); // Local state for FAB animation, not chat visibility
|
/>
|
||||||
|
</q-inner-loading>
|
||||||
// Computed properties to get state from the store
|
<q-banner
|
||||||
const isChatVisible = computed(() => chatStore.isChatVisible);
|
v-if="chatError"
|
||||||
const chatMessages = computed(() => chatStore.chatMessages);
|
inline-actions
|
||||||
const isLoading = computed(() => chatStore.isLoading);
|
class="text-white bg-red"
|
||||||
const chatError = computed(() => chatStore.error);
|
>
|
||||||
const isAuthenticated = computed(() => authStore.isAuthenticated); // Get auth state
|
{{ chatError }}
|
||||||
|
<template #action>
|
||||||
// Get the child routes of the main layout
|
<q-btn
|
||||||
const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || [];
|
flat
|
||||||
|
color="white"
|
||||||
// Compute navigation items based on auth state and route meta
|
label="Dismiss"
|
||||||
const navItems = computed(() =>
|
@click="clearError"
|
||||||
{
|
/>
|
||||||
return mainLayoutRoutes.filter(route =>
|
</template>
|
||||||
{
|
</q-banner>
|
||||||
const navGroup = route.meta?.navGroup;
|
</q-card>
|
||||||
if (!navGroup) return false; // Only include routes with navGroup defined
|
</q-dialog>
|
||||||
|
</q-layout>
|
||||||
if (navGroup === 'always') return true;
|
</template>
|
||||||
if (navGroup === 'auth' && isAuthenticated.value) return true;
|
|
||||||
if (navGroup === 'noAuth' && !isAuthenticated.value) return true;
|
<script setup>
|
||||||
|
import axios from 'boot/axios';
|
||||||
return false; // Exclude otherwise
|
import { ref, computed } from 'vue'; // Import computed
|
||||||
});
|
import { useRouter } from 'vue-router';
|
||||||
});
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
||||||
|
import { useChatStore } from 'stores/chat'; // Adjust path as needed
|
||||||
// Method to toggle chat visibility via the store action
|
import ChatInterface from 'components/ChatInterface.vue'; // Adjust path as needed
|
||||||
const toggleChat = () =>
|
import routes from '../router/routes'; // Import routes
|
||||||
{
|
|
||||||
// Optional: Add an extra check here if needed, though hiding the button is primary
|
const $q = useQuasar();
|
||||||
if (isAuthenticated.value)
|
const leftDrawerOpen = ref(false);
|
||||||
{
|
const router = useRouter();
|
||||||
chatStore.toggleChat();
|
const authStore = useAuthStore(); // Use the auth store
|
||||||
}
|
const chatStore = useChatStore();
|
||||||
};
|
|
||||||
|
const fabOpen = ref(false); // Local state for FAB animation, not chat visibility
|
||||||
// Method to send a message via the store action
|
|
||||||
const handleSendMessage = (messageContent) =>
|
// Computed properties to get state from the store
|
||||||
{
|
const isChatVisible = computed(() => chatStore.isChatVisible);
|
||||||
chatStore.sendMessage(messageContent);
|
const chatMessages = computed(() => chatStore.chatMessages);
|
||||||
};
|
const isLoading = computed(() => chatStore.isLoading);
|
||||||
|
const chatError = computed(() => chatStore.error);
|
||||||
// Method to clear errors in the store (optional)
|
const isAuthenticated = computed(() => authStore.isAuthenticated); // Get auth state
|
||||||
const clearError = () =>
|
|
||||||
{
|
// Get the child routes of the main layout
|
||||||
chatStore.error = null; // Directly setting ref or add an action in store
|
const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || [];
|
||||||
};
|
|
||||||
function toggleLeftDrawer()
|
// Compute navigation items based on auth state and route meta
|
||||||
{
|
const navItems = computed(() =>
|
||||||
leftDrawerOpen.value = !leftDrawerOpen.value;
|
{
|
||||||
}
|
return mainLayoutRoutes.filter(route =>
|
||||||
|
{
|
||||||
async function logout()
|
const navGroup = route.meta?.navGroup;
|
||||||
{
|
if (!navGroup) return false; // Only include routes with navGroup defined
|
||||||
try
|
|
||||||
{
|
if (navGroup === 'always') return true;
|
||||||
await axios.post('/api/auth/logout');
|
if (navGroup === 'auth' && isAuthenticated.value) return true;
|
||||||
authStore.logout(); // Use the store action to update state
|
if (navGroup === 'noAuth' && !isAuthenticated.value) return true;
|
||||||
// No need to manually push, router guard should redirect
|
|
||||||
// router.push({ name: 'login' });
|
return false; // Exclude otherwise
|
||||||
}
|
});
|
||||||
catch (error)
|
});
|
||||||
{
|
|
||||||
console.error('Logout failed:', error);
|
|
||||||
|
// Method to toggle chat visibility via the store action
|
||||||
$q.notify({
|
const toggleChat = () =>
|
||||||
color: 'negative',
|
{
|
||||||
message: 'Logout failed. Please try again.',
|
// Optional: Add an extra check here if needed, though hiding the button is primary
|
||||||
icon: 'report_problem'
|
if (isAuthenticated.value)
|
||||||
});
|
{
|
||||||
}
|
chatStore.toggleChat();
|
||||||
}
|
}
|
||||||
</script>
|
};
|
||||||
|
|
||||||
<style scoped>
|
// Method to send a message via the store action
|
||||||
/* Add any specific styles for the layout or chat window here */
|
const handleSendMessage = (messageContent) =>
|
||||||
.q-dialog .q-card {
|
{
|
||||||
overflow: hidden; /* Prevent scrollbars on the card itself */
|
chatStore.sendMessage(messageContent);
|
||||||
}
|
};
|
||||||
</style>
|
|
||||||
|
// Method to clear errors in the store (optional)
|
||||||
|
const clearError = () =>
|
||||||
|
{
|
||||||
|
chatStore.error = null; // Directly setting ref or add an action in store
|
||||||
|
};
|
||||||
|
function toggleLeftDrawer()
|
||||||
|
{
|
||||||
|
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||||
|
}
|
||||||
|
async function logout()
|
||||||
|
{
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Confirm Logout',
|
||||||
|
message: 'Are you sure you want to logout?',
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await axios.post('/api/auth/logout');
|
||||||
|
authStore.logout();
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
color: 'positive',
|
||||||
|
message: 'Logout successful.',
|
||||||
|
icon: 'check_circle'
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push({ name: 'login' });
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error('Logout failed:', error);
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
color: 'negative',
|
||||||
|
message: 'Logout failed. Please try again.',
|
||||||
|
icon: 'report_problem'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.q-dialog .q-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-list .q-item {
|
||||||
|
border-radius: 32px;
|
||||||
|
margin: 5px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-list .q-item:first-child {
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-list .q-router-link--active {
|
||||||
|
background-color: var(--q-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -1,220 +1,220 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<div class="text-h4 q-mb-md">
|
<div class="text-h4 q-mb-md">
|
||||||
Create New Form
|
Create New Form
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-form
|
<q-form
|
||||||
@submit.prevent="createForm"
|
@submit.prevent="createForm"
|
||||||
class="q-gutter-md"
|
class="q-gutter-md"
|
||||||
>
|
>
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
outlined
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
label="Form Title *"
|
label="Form Title *"
|
||||||
lazy-rules
|
lazy-rules
|
||||||
:rules="[val => val && val.length > 0 || 'Please enter a title']"
|
:rules="[val => val && val.length > 0 || 'Please enter a title']"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
outlined
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
label="Form Description"
|
label="Form Description"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
autogrow
|
autogrow
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-separator class="q-my-lg" />
|
<q-separator class="q-my-lg" />
|
||||||
|
|
||||||
<div class="text-h6 q-mb-sm">
|
<div class="text-h6 q-mb-sm">
|
||||||
Categories & Fields
|
Categories & Fields
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="(category, catIndex) in form.categories"
|
v-for="(category, catIndex) in form.categories"
|
||||||
:key="catIndex"
|
:key="catIndex"
|
||||||
class="q-mb-lg q-pa-md bordered rounded-borders"
|
class="q-mb-lg q-pa-md bordered rounded-borders"
|
||||||
>
|
>
|
||||||
<div class="row items-center q-mb-sm">
|
<div class="row items-center q-mb-sm">
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
v-model="category.name"
|
v-model="category.name"
|
||||||
:label="`Category ${catIndex + 1} Name *`"
|
:label="`Category ${catIndex + 1} Name *`"
|
||||||
class="col q-mr-sm"
|
class="col q-mr-sm"
|
||||||
lazy-rules
|
lazy-rules
|
||||||
:rules="[val => val && val.length > 0 || 'Category name required']"
|
:rules="[val => val && val.length > 0 || 'Category name required']"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
dense
|
dense
|
||||||
icon="delete"
|
icon="delete"
|
||||||
color="negative"
|
color="negative"
|
||||||
@click="removeCategory(catIndex)"
|
@click="removeCategory(catIndex)"
|
||||||
title="Remove Category"
|
title="Remove Category"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="(field, fieldIndex) in category.fields"
|
v-for="(field, fieldIndex) in category.fields"
|
||||||
:key="fieldIndex"
|
:key="fieldIndex"
|
||||||
class="q-ml-md q-mb-sm field-item"
|
class="q-ml-md q-mb-sm field-item"
|
||||||
>
|
>
|
||||||
<div class="row items-center q-gutter-sm">
|
<div class="row items-center q-gutter-sm">
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
v-model="field.label"
|
v-model="field.label"
|
||||||
label="Field Label *"
|
label="Field Label *"
|
||||||
class="col"
|
class="col"
|
||||||
lazy-rules
|
lazy-rules
|
||||||
:rules="[val => val && val.length > 0 || 'Field label required']"
|
:rules="[val => val && val.length > 0 || 'Field label required']"
|
||||||
/>
|
/>
|
||||||
<q-select
|
<q-select
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
v-model="field.type"
|
v-model="field.type"
|
||||||
:options="fieldTypes"
|
:options="fieldTypes"
|
||||||
label="Field Type *"
|
label="Field Type *"
|
||||||
class="col-auto"
|
class="col-auto"
|
||||||
style="min-width: 150px;"
|
style="min-width: 150px;"
|
||||||
lazy-rules
|
lazy-rules
|
||||||
:rules="[val => !!val || 'Field type required']"
|
:rules="[val => !!val || 'Field type required']"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
dense
|
dense
|
||||||
icon="delete"
|
icon="delete"
|
||||||
color="negative"
|
color="negative"
|
||||||
@click="removeField(catIndex, fieldIndex)"
|
@click="removeField(catIndex, fieldIndex)"
|
||||||
title="Remove Field"
|
title="Remove Field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="field.description"
|
v-model="field.description"
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
label="Field Description (Optional)"
|
label="Field Description (Optional)"
|
||||||
autogrow
|
autogrow
|
||||||
class="q-mt-xs q-mb-xl"
|
class="q-mt-xs q-mb-xl"
|
||||||
hint="This description will appear below the field label on the form."
|
hint="This description will appear below the field label on the form."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
<q-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
label="Add Field"
|
label="Add Field"
|
||||||
@click="addField(catIndex)"
|
@click="addField(catIndex)"
|
||||||
class="q-ml-md q-mt-sm"
|
class="q-ml-md q-mt-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-btn
|
<q-btn
|
||||||
color="secondary"
|
color="secondary"
|
||||||
label="Add Category"
|
label="Add Category"
|
||||||
@click="addCategory"
|
@click="addCategory"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-separator class="q-my-lg" />
|
<q-separator class="q-my-lg" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<q-btn
|
<q-btn
|
||||||
label="Create Form"
|
label="Create Form"
|
||||||
type="submit"
|
type="submit"
|
||||||
color="primary"
|
color="primary"
|
||||||
:loading="submitting"
|
:loading="submitting"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
label="Cancel"
|
label="Cancel"
|
||||||
type="reset"
|
type="reset"
|
||||||
color="warning"
|
color="warning"
|
||||||
class="q-ml-sm"
|
class="q-ml-sm"
|
||||||
:to="{ name: 'formList' }"
|
:to="{ name: 'formList' }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import axios from 'boot/axios';
|
import axios from 'boot/axios';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
categories: [
|
categories: [
|
||||||
{ name: 'Category 1', fields: [{ label: '', type: null, description: '' }] }
|
{ name: 'Category 1', fields: [{ label: '', type: null, description: '' }] }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
|
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
|
|
||||||
function addCategory()
|
function addCategory()
|
||||||
{
|
{
|
||||||
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] });
|
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] });
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeCategory(index)
|
function removeCategory(index)
|
||||||
{
|
{
|
||||||
form.value.categories.splice(index, 1);
|
form.value.categories.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addField(catIndex)
|
function addField(catIndex)
|
||||||
{
|
{
|
||||||
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
|
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeField(catIndex, fieldIndex)
|
function removeField(catIndex, fieldIndex)
|
||||||
{
|
{
|
||||||
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
|
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createForm()
|
async function createForm()
|
||||||
{
|
{
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await axios.post('/api/forms', form.value);
|
const response = await axios.post('/api/forms', form.value);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'positive',
|
color: 'positive',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: `Form "${form.value.title}" created successfully!`,
|
message: `Form "${form.value.title}" created successfully!`,
|
||||||
icon: 'check_circle'
|
icon: 'check_circle'
|
||||||
});
|
});
|
||||||
router.push({ name: 'formList' });
|
router.push({ name: 'formList' });
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error creating form:', error);
|
console.error('Error creating form:', error);
|
||||||
const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.';
|
const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.';
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: message,
|
message: message,
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.bordered {
|
.bordered {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rounded-borders {
|
.rounded-borders {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,285 +1,285 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<div class="text-h4 q-mb-md">
|
<div class="text-h4 q-mb-md">
|
||||||
Edit Form
|
Edit Form
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-form
|
<q-form
|
||||||
v-if="!loading && form"
|
v-if="!loading && form"
|
||||||
@submit.prevent="updateForm"
|
@submit.prevent="updateForm"
|
||||||
class="q-gutter-md"
|
class="q-gutter-md"
|
||||||
>
|
>
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
outlined
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
label="Form Title *"
|
label="Form Title *"
|
||||||
lazy-rules
|
lazy-rules
|
||||||
:rules="[ val => val && val.length > 0 || 'Please enter a title']"
|
:rules="[ val => val && val.length > 0 || 'Please enter a title']"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
outlined
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
label="Form Description"
|
label="Form Description"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
autogrow
|
autogrow
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-separator class="q-my-lg" />
|
<q-separator class="q-my-lg" />
|
||||||
|
|
||||||
<div class="text-h6 q-mb-sm">
|
<div class="text-h6 q-mb-sm">
|
||||||
Categories & Fields
|
Categories & Fields
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="(category, catIndex) in form.categories"
|
v-for="(category, catIndex) in form.categories"
|
||||||
:key="category.id || catIndex"
|
:key="category.id || catIndex"
|
||||||
class="q-mb-lg q-pa-md bordered rounded-borders"
|
class="q-mb-lg q-pa-md bordered rounded-borders"
|
||||||
>
|
>
|
||||||
<div class="row items-center q-mb-sm">
|
<div class="row items-center q-mb-sm">
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
v-model="category.name"
|
v-model="category.name"
|
||||||
:label="`Category ${catIndex + 1} Name *`"
|
:label="`Category ${catIndex + 1} Name *`"
|
||||||
class="col q-mr-sm"
|
class="col q-mr-sm"
|
||||||
lazy-rules
|
lazy-rules
|
||||||
:rules="[ val => val && val.length > 0 || 'Category name required']"
|
:rules="[ val => val && val.length > 0 || 'Category name required']"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
dense
|
dense
|
||||||
icon="delete"
|
icon="delete"
|
||||||
color="negative"
|
color="negative"
|
||||||
@click="removeCategory(catIndex)"
|
@click="removeCategory(catIndex)"
|
||||||
title="Remove Category"
|
title="Remove Category"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="(field, fieldIndex) in category.fields"
|
v-for="(field, fieldIndex) in category.fields"
|
||||||
:key="field.id || fieldIndex"
|
:key="field.id || fieldIndex"
|
||||||
class="q-ml-md q-mb-sm"
|
class="q-ml-md q-mb-sm"
|
||||||
>
|
>
|
||||||
<div class="row items-center q-gutter-sm">
|
<div class="row items-center q-gutter-sm">
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
v-model="field.label"
|
v-model="field.label"
|
||||||
label="Field Label *"
|
label="Field Label *"
|
||||||
class="col"
|
class="col"
|
||||||
lazy-rules
|
lazy-rules
|
||||||
:rules="[ val => val && val.length > 0 || 'Field label required']"
|
:rules="[ val => val && val.length > 0 || 'Field label required']"
|
||||||
/>
|
/>
|
||||||
<q-select
|
<q-select
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
v-model="field.type"
|
v-model="field.type"
|
||||||
:options="fieldTypes"
|
:options="fieldTypes"
|
||||||
label="Field Type *"
|
label="Field Type *"
|
||||||
class="col-auto"
|
class="col-auto"
|
||||||
style="min-width: 150px;"
|
style="min-width: 150px;"
|
||||||
lazy-rules
|
lazy-rules
|
||||||
:rules="[ val => !!val || 'Field type required']"
|
:rules="[ val => !!val || 'Field type required']"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
dense
|
dense
|
||||||
icon="delete"
|
icon="delete"
|
||||||
color="negative"
|
color="negative"
|
||||||
@click="removeField(catIndex, fieldIndex)"
|
@click="removeField(catIndex, fieldIndex)"
|
||||||
title="Remove Field"
|
title="Remove Field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="field.description"
|
v-model="field.description"
|
||||||
label="Field Description (Optional)"
|
label="Field Description (Optional)"
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
autogrow
|
autogrow
|
||||||
class="q-mt-xs q-mb-xl"
|
class="q-mt-xs q-mb-xl"
|
||||||
hint="This description will appear below the field label on the form."
|
hint="This description will appear below the field label on the form."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="primary"
|
color="primary"
|
||||||
label="Add Field"
|
label="Add Field"
|
||||||
@click="addField(catIndex)"
|
@click="addField(catIndex)"
|
||||||
class="q-ml-md q-mt-sm"
|
class="q-ml-md q-mt-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="secondary"
|
color="secondary"
|
||||||
label="Add Category"
|
label="Add Category"
|
||||||
@click="addCategory"
|
@click="addCategory"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-separator class="q-my-lg" />
|
<q-separator class="q-my-lg" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
label="Update Form"
|
label="Update Form"
|
||||||
type="submit"
|
type="submit"
|
||||||
color="primary"
|
color="primary"
|
||||||
:loading="submitting"
|
:loading="submitting"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
label="Cancel"
|
label="Cancel"
|
||||||
type="reset"
|
type="reset"
|
||||||
color="warning"
|
color="warning"
|
||||||
class="q-ml-sm"
|
class="q-ml-sm"
|
||||||
:to="{ name: 'formList' }"
|
:to="{ name: 'formList' }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</q-form>
|
</q-form>
|
||||||
<div v-else-if="loading">
|
<div v-else-if="loading">
|
||||||
<q-spinner-dots
|
<q-spinner-dots
|
||||||
color="primary"
|
color="primary"
|
||||||
size="40px"
|
size="40px"
|
||||||
/>
|
/>
|
||||||
Loading form details...
|
Loading form details...
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="text-negative"
|
class="text-negative"
|
||||||
>
|
>
|
||||||
Failed to load form details.
|
Failed to load form details.
|
||||||
</div>
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import axios from 'boot/axios';
|
import axios from 'boot/axios';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute(); // Use useRoute if needed, though id is from props
|
const route = useRoute(); // Use useRoute if needed, though id is from props
|
||||||
|
|
||||||
const form = ref(null); // Initialize as null
|
const form = ref(null); // Initialize as null
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
|
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
|
|
||||||
async function fetchForm()
|
async function fetchForm()
|
||||||
{
|
{
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await axios.get(`/api/forms/${props.id}`);
|
const response = await axios.get(`/api/forms/${props.id}`);
|
||||||
// Ensure categories and fields exist, even if empty
|
// Ensure categories and fields exist, even if empty
|
||||||
response.data.categories = response.data.categories || [];
|
response.data.categories = response.data.categories || [];
|
||||||
response.data.categories.forEach(cat =>
|
response.data.categories.forEach(cat =>
|
||||||
{
|
{
|
||||||
cat.fields = cat.fields || [];
|
cat.fields = cat.fields || [];
|
||||||
});
|
});
|
||||||
form.value = response.data;
|
form.value = response.data;
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error fetching form details:', error);
|
console.error('Error fetching form details:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: 'Failed to load form details.',
|
message: 'Failed to load form details.',
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
form.value = null; // Indicate failure
|
form.value = null; // Indicate failure
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchForm);
|
onMounted(fetchForm);
|
||||||
|
|
||||||
function addCategory()
|
function addCategory()
|
||||||
{
|
{
|
||||||
if (!form.value.categories)
|
if (!form.value.categories)
|
||||||
{
|
{
|
||||||
form.value.categories = [];
|
form.value.categories = [];
|
||||||
}
|
}
|
||||||
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] });
|
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] });
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeCategory(index)
|
function removeCategory(index)
|
||||||
{
|
{
|
||||||
form.value.categories.splice(index, 1);
|
form.value.categories.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addField(catIndex)
|
function addField(catIndex)
|
||||||
{
|
{
|
||||||
if (!form.value.categories[catIndex].fields)
|
if (!form.value.categories[catIndex].fields)
|
||||||
{
|
{
|
||||||
form.value.categories[catIndex].fields = [];
|
form.value.categories[catIndex].fields = [];
|
||||||
}
|
}
|
||||||
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
|
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeField(catIndex, fieldIndex)
|
function removeField(catIndex, fieldIndex)
|
||||||
{
|
{
|
||||||
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
|
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateForm()
|
async function updateForm()
|
||||||
{
|
{
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Prepare payload, potentially removing temporary IDs if any were added client-side
|
// Prepare payload, potentially removing temporary IDs if any were added client-side
|
||||||
const payload = JSON.parse(JSON.stringify(form.value));
|
const payload = JSON.parse(JSON.stringify(form.value));
|
||||||
// The backend PUT expects title, description, categories (with name, fields (with label, type, description))
|
// 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
|
// 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);
|
await axios.put(`/api/forms/${props.id}`, payload);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'positive',
|
color: 'positive',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: `Form "${form.value.title}" updated successfully!`,
|
message: `Form "${form.value.title}" updated successfully!`,
|
||||||
icon: 'check_circle'
|
icon: 'check_circle'
|
||||||
});
|
});
|
||||||
router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
|
router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error updating form:', error);
|
console.error('Error updating form:', error);
|
||||||
const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.';
|
const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.';
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: message,
|
message: message,
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.bordered {
|
.bordered {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
.rounded-borders {
|
.rounded-borders {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,223 +1,223 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<q-inner-loading :showing="loading">
|
<q-inner-loading :showing="loading">
|
||||||
<q-spinner-gears
|
<q-spinner-gears
|
||||||
size="50px"
|
size="50px"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</q-inner-loading>
|
</q-inner-loading>
|
||||||
|
|
||||||
<div v-if="!loading && form">
|
<div v-if="!loading && form">
|
||||||
<div class="text-h4 q-mb-xs">
|
<div class="text-h4 q-mb-xs">
|
||||||
{{ form.title }}
|
{{ form.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-subtitle1 text-grey q-mb-lg">
|
<div class="text-subtitle1 text-grey q-mb-lg">
|
||||||
{{ form.description }}
|
{{ form.description }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-form
|
<q-form
|
||||||
@submit.prevent="submitResponse"
|
@submit.prevent="submitResponse"
|
||||||
class="q-gutter-md"
|
class="q-gutter-md"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="category in form.categories"
|
v-for="category in form.categories"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
class="q-mb-lg"
|
class="q-mb-lg"
|
||||||
>
|
>
|
||||||
<div class="text-h6 q-mb-sm">
|
<div class="text-h6 q-mb-sm">
|
||||||
{{ category.name }}
|
{{ category.name }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="field in category.fields"
|
v-for="field in category.fields"
|
||||||
:key="field.id"
|
:key="field.id"
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
<q-item-label class="q-mb-xs">
|
<q-item-label class="q-mb-xs">
|
||||||
{{ field.label }}
|
{{ field.label }}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
<q-item-label
|
<q-item-label
|
||||||
caption
|
caption
|
||||||
v-if="field.description"
|
v-if="field.description"
|
||||||
class="q-mb-xs text-grey-7"
|
class="q-mb-xs text-grey-7"
|
||||||
>
|
>
|
||||||
{{ field.description }}
|
{{ field.description }}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
<q-input
|
<q-input
|
||||||
v-if="field.type === 'text'"
|
v-if="field.type === 'text'"
|
||||||
outlined
|
outlined
|
||||||
v-model="responses[field.id]"
|
v-model="responses[field.id]"
|
||||||
:label="field.label"
|
:label="field.label"
|
||||||
/>
|
/>
|
||||||
<q-input
|
<q-input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
outlined
|
outlined
|
||||||
type="number"
|
type="number"
|
||||||
v-model.number="responses[field.id]"
|
v-model.number="responses[field.id]"
|
||||||
:label="field.label"
|
:label="field.label"
|
||||||
/>
|
/>
|
||||||
<q-input
|
<q-input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
outlined
|
outlined
|
||||||
type="date"
|
type="date"
|
||||||
v-model="responses[field.id]"
|
v-model="responses[field.id]"
|
||||||
:label="field.label"
|
:label="field.label"
|
||||||
stack-label
|
stack-label
|
||||||
/>
|
/>
|
||||||
<q-input
|
<q-input
|
||||||
v-else-if="field.type === 'textarea'"
|
v-else-if="field.type === 'textarea'"
|
||||||
outlined
|
outlined
|
||||||
type="textarea"
|
type="textarea"
|
||||||
autogrow
|
autogrow
|
||||||
v-model="responses[field.id]"
|
v-model="responses[field.id]"
|
||||||
:label="field.label"
|
:label="field.label"
|
||||||
/>
|
/>
|
||||||
<q-checkbox
|
<q-checkbox
|
||||||
v-else-if="field.type === 'boolean'"
|
v-else-if="field.type === 'boolean'"
|
||||||
v-model="responses[field.id]"
|
v-model="responses[field.id]"
|
||||||
:label="field.label"
|
:label="field.label"
|
||||||
left-label
|
left-label
|
||||||
class="q-mt-sm"
|
class="q-mt-sm"
|
||||||
/>
|
/>
|
||||||
<!-- Add other field types as needed -->
|
<!-- Add other field types as needed -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-separator class="q-my-lg" />
|
<q-separator class="q-my-lg" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
label="Submit Response"
|
label="Submit Response"
|
||||||
type="submit"
|
type="submit"
|
||||||
color="primary"
|
color="primary"
|
||||||
:loading="submitting"
|
:loading="submitting"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
label="Cancel"
|
label="Cancel"
|
||||||
type="reset"
|
type="reset"
|
||||||
color="default"
|
color="default"
|
||||||
class="q-ml-sm"
|
class="q-ml-sm"
|
||||||
:to="{ name: 'formList' }"
|
:to="{ name: 'formList' }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</q-form>
|
</q-form>
|
||||||
</div>
|
</div>
|
||||||
<q-banner
|
<q-banner
|
||||||
v-else-if="!loading && !form"
|
v-else-if="!loading && !form"
|
||||||
class="bg-negative text-white"
|
class="bg-negative text-white"
|
||||||
>
|
>
|
||||||
<template #avatar>
|
<template #avatar>
|
||||||
<q-icon name="error" />
|
<q-icon name="error" />
|
||||||
</template>
|
</template>
|
||||||
Form not found or could not be loaded.
|
Form not found or could not be loaded.
|
||||||
<template #action>
|
<template #action>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
color="white"
|
color="white"
|
||||||
label="Back to Forms"
|
label="Back to Forms"
|
||||||
:to="{ name: 'formList' }"
|
:to="{ name: 'formList' }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, reactive } from 'vue';
|
import { ref, onMounted, reactive } from 'vue';
|
||||||
import axios from 'boot/axios';
|
import axios from 'boot/axios';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const form = ref(null);
|
const form = ref(null);
|
||||||
const responses = reactive({}); // Use reactive for dynamic properties
|
const responses = reactive({}); // Use reactive for dynamic properties
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
|
|
||||||
async function fetchFormDetails()
|
async function fetchFormDetails()
|
||||||
{
|
{
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
form.value = null; // Reset form data
|
form.value = null; // Reset form data
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await axios.get(`/api/forms/${props.id}`);
|
const response = await axios.get(`/api/forms/${props.id}`);
|
||||||
form.value = response.data;
|
form.value = response.data;
|
||||||
// Initialize responses object based on fields
|
// Initialize responses object based on fields
|
||||||
form.value.categories.forEach(cat =>
|
form.value.categories.forEach(cat =>
|
||||||
{
|
{
|
||||||
cat.fields.forEach(field =>
|
cat.fields.forEach(field =>
|
||||||
{
|
{
|
||||||
responses[field.id] = null; // Initialize all fields to null or default
|
responses[field.id] = null; // Initialize all fields to null or default
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error(`Error fetching form ${props.id}:`, error);
|
console.error(`Error fetching form ${props.id}:`, error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: 'Failed to load form details.',
|
message: 'Failed to load form details.',
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitResponse()
|
async function submitResponse()
|
||||||
{
|
{
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Basic check if any response is provided (optional)
|
// Basic check if any response is provided (optional)
|
||||||
// const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
|
// const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
|
||||||
// if (!hasResponse) {
|
// if (!hasResponse) {
|
||||||
// $q.notify({ color: 'warning', message: 'Please fill in at least one field.' });
|
// $q.notify({ color: 'warning', message: 'Please fill in at least one field.' });
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
await axios.post(`/api/forms/${props.id}/responses`, { values: responses });
|
await axios.post(`/api/forms/${props.id}/responses`, { values: responses });
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'positive',
|
color: 'positive',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: 'Response submitted successfully!',
|
message: 'Response submitted successfully!',
|
||||||
icon: 'check_circle'
|
icon: 'check_circle'
|
||||||
});
|
});
|
||||||
// Optionally redirect or clear form
|
// Optionally redirect or clear form
|
||||||
router.push({ name: 'formResponses', params: { id: props.id } }); // Go to responses page after submit
|
router.push({ name: 'formResponses', params: { id: props.id } }); // Go to responses page after submit
|
||||||
// Or clear the form:
|
// Or clear the form:
|
||||||
// Object.keys(responses).forEach(key => { responses[key] = null; });
|
// Object.keys(responses).forEach(key => { responses[key] = null; });
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error submitting response:', error);
|
console.error('Error submitting response:', error);
|
||||||
const message = error.response?.data?.error || 'Failed to submit response.';
|
const message = error.response?.data?.error || 'Failed to submit response.';
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: message,
|
message: message,
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchFormDetails);
|
onMounted(fetchFormDetails);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,187 +1,187 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<div class="q-mb-md row justify-between items-center">
|
<div class="q-mb-md row justify-between items-center">
|
||||||
<div class="text-h4">
|
<div class="text-h4">
|
||||||
Forms
|
Forms
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
label="Create New Form"
|
label="Create New Form"
|
||||||
color="primary"
|
color="primary"
|
||||||
:to="{ name: 'formCreate' }"
|
:to="{ name: 'formCreate' }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-list
|
<q-list
|
||||||
bordered
|
bordered
|
||||||
separator
|
separator
|
||||||
v-if="forms.length > 0"
|
v-if="forms.length > 0"
|
||||||
>
|
>
|
||||||
<q-item
|
<q-item
|
||||||
v-for="form in forms"
|
v-for="form in forms"
|
||||||
:key="form.id"
|
:key="form.id"
|
||||||
>
|
>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{{ form.title }}</q-item-label>
|
<q-item-label>{{ form.title }}</q-item-label>
|
||||||
<q-item-label caption>
|
<q-item-label caption>
|
||||||
{{ form.description || 'No description' }}
|
{{ form.description || 'No description' }}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
<q-item-label caption>
|
<q-item-label caption>
|
||||||
Created: {{ formatDate(form.createdAt) }}
|
Created: {{ formatDate(form.createdAt) }}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<div class="q-gutter-sm">
|
<div class="q-gutter-sm">
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
dense
|
dense
|
||||||
icon="edit_note"
|
icon="edit_note"
|
||||||
color="info"
|
color="info"
|
||||||
:to="{ name: 'formFill', params: { id: form.id } }"
|
:to="{ name: 'formFill', params: { id: form.id } }"
|
||||||
title="Fill Form"
|
title="Fill Form"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
dense
|
dense
|
||||||
icon="visibility"
|
icon="visibility"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
:to="{ name: 'formResponses', params: { id: form.id } }"
|
:to="{ name: 'formResponses', params: { id: form.id } }"
|
||||||
title="View Responses"
|
title="View Responses"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
dense
|
dense
|
||||||
icon="edit"
|
icon="edit"
|
||||||
color="warning"
|
color="warning"
|
||||||
:to="{ name: 'formEdit', params: { id: form.id } }"
|
:to="{ name: 'formEdit', params: { id: form.id } }"
|
||||||
title="Edit Form"
|
title="Edit Form"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
dense
|
dense
|
||||||
icon="delete"
|
icon="delete"
|
||||||
color="negative"
|
color="negative"
|
||||||
@click.stop="confirmDeleteForm(form.id)"
|
@click.stop="confirmDeleteForm(form.id)"
|
||||||
title="Delete Form"
|
title="Delete Form"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
<q-banner
|
<q-banner
|
||||||
v-else
|
v-else
|
||||||
class="bg-info text-white"
|
class="bg-info text-white"
|
||||||
>
|
>
|
||||||
<template #avatar>
|
<template #avatar>
|
||||||
<q-icon
|
<q-icon
|
||||||
name="info"
|
name="info"
|
||||||
color="white"
|
color="white"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
No forms created yet. Click the button above to create your first form.
|
No forms created yet. Click the button above to create your first form.
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-inner-loading :showing="loading">
|
<q-inner-loading :showing="loading">
|
||||||
<q-spinner-gears
|
<q-spinner-gears
|
||||||
size="50px"
|
size="50px"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</q-inner-loading>
|
</q-inner-loading>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import axios from 'boot/axios';
|
import axios from 'boot/axios';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
|
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
const forms = ref([]);
|
const forms = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
async function fetchForms()
|
async function fetchForms()
|
||||||
{
|
{
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await axios.get('/api/forms');
|
const response = await axios.get('/api/forms');
|
||||||
forms.value = response.data;
|
forms.value = response.data;
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error fetching forms:', error);
|
console.error('Error fetching forms:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: 'Failed to load forms. Please try again later.',
|
message: 'Failed to load forms. Please try again later.',
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add function to handle delete confirmation
|
// Add function to handle delete confirmation
|
||||||
function confirmDeleteForm(id)
|
function confirmDeleteForm(id)
|
||||||
{
|
{
|
||||||
$q.dialog({
|
$q.dialog({
|
||||||
title: 'Confirm Delete',
|
title: 'Confirm Delete',
|
||||||
message: 'Are you sure you want to delete this form and all its responses? This action cannot be undone.',
|
message: 'Are you sure you want to delete this form and all its responses? This action cannot be undone.',
|
||||||
cancel: true,
|
cancel: true,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
ok: {
|
ok: {
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
flat: false
|
flat: false
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: {
|
||||||
label: 'Cancel',
|
label: 'Cancel',
|
||||||
flat: true
|
flat: true
|
||||||
}
|
}
|
||||||
}).onOk(() =>
|
}).onOk(() =>
|
||||||
{
|
{
|
||||||
deleteForm(id);
|
deleteForm(id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add function to call the delete API
|
// Add function to call the delete API
|
||||||
async function deleteForm(id)
|
async function deleteForm(id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await axios.delete(`/api/forms/${id}`);
|
await axios.delete(`/api/forms/${id}`);
|
||||||
forms.value = forms.value.filter(form => form.id !== id);
|
forms.value = forms.value.filter(form => form.id !== id);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'positive',
|
color: 'positive',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: 'Form deleted successfully.',
|
message: 'Form deleted successfully.',
|
||||||
icon: 'check_circle'
|
icon: 'check_circle'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error(`Error deleting form ${id}:`, error);
|
console.error(`Error deleting form ${id}:`, error);
|
||||||
const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.';
|
const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.';
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add function to format date
|
// Add function to format date
|
||||||
function formatDate(date)
|
function formatDate(date)
|
||||||
{
|
{
|
||||||
return new Date(date).toLocaleString();
|
return new Date(date).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchForms);
|
onMounted(fetchForms);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,250 +1,250 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<q-inner-loading :showing="loading">
|
<q-inner-loading :showing="loading">
|
||||||
<q-spinner-gears
|
<q-spinner-gears
|
||||||
size="50px"
|
size="50px"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</q-inner-loading>
|
</q-inner-loading>
|
||||||
|
|
||||||
<div v-if="!loading && formTitle">
|
<div v-if="!loading && formTitle">
|
||||||
<div class="row justify-between items-center q-mb-md">
|
<div class="row justify-between items-center q-mb-md">
|
||||||
<div class="text-h4">
|
<div class="text-h4">
|
||||||
Responses for: {{ formTitle }}
|
Responses for: {{ formTitle }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Search Input -->
|
<!-- Add Search Input -->
|
||||||
<q-input
|
<q-input
|
||||||
v-if="responses.length > 0"
|
v-if="responses.length > 0"
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
debounce="300"
|
debounce="300"
|
||||||
v-model="filterText"
|
v-model="filterText"
|
||||||
placeholder="Search responses..."
|
placeholder="Search responses..."
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
<template #append>
|
<template #append>
|
||||||
<q-icon name="search" />
|
<q-icon name="search" />
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
||||||
<q-table
|
<q-table
|
||||||
v-if="responses.length > 0"
|
v-if="responses.length > 0"
|
||||||
:rows="formattedResponses"
|
:rows="formattedResponses"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
separator="cell"
|
separator="cell"
|
||||||
wrap-cells
|
wrap-cells
|
||||||
:filter="filterText"
|
:filter="filterText"
|
||||||
>
|
>
|
||||||
<template #body-cell-submittedAt="props">
|
<template #body-cell-submittedAt="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
{{ new Date(props.value).toLocaleString() }}
|
{{ new Date(props.value).toLocaleString() }}
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Slot for Actions column -->
|
<!-- Slot for Actions column -->
|
||||||
<template #body-cell-actions="props">
|
<template #body-cell-actions="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
round
|
round
|
||||||
icon="download"
|
icon="download"
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="downloadResponsePdf(props.row.id)"
|
@click="downloadResponsePdf(props.row.id)"
|
||||||
aria-label="Download PDF"
|
aria-label="Download PDF"
|
||||||
>
|
>
|
||||||
<q-tooltip>Download PDF</q-tooltip>
|
<q-tooltip>Download PDF</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
|
|
||||||
<q-banner
|
<q-banner
|
||||||
v-else
|
v-else
|
||||||
class=""
|
class=""
|
||||||
>
|
>
|
||||||
<template #avatar>
|
<template #avatar>
|
||||||
<q-icon
|
<q-icon
|
||||||
name="info"
|
name="info"
|
||||||
color="info"
|
color="info"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
No responses have been submitted for this form yet.
|
No responses have been submitted for this form yet.
|
||||||
</q-banner>
|
</q-banner>
|
||||||
</div>
|
</div>
|
||||||
<q-banner
|
<q-banner
|
||||||
v-else-if="!loading && !formTitle"
|
v-else-if="!loading && !formTitle"
|
||||||
class="bg-negative text-white"
|
class="bg-negative text-white"
|
||||||
>
|
>
|
||||||
<template #avatar>
|
<template #avatar>
|
||||||
<q-icon name="error" />
|
<q-icon name="error" />
|
||||||
</template>
|
</template>
|
||||||
Form not found or could not load responses.
|
Form not found or could not load responses.
|
||||||
<template #action>
|
<template #action>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
color="white"
|
color="white"
|
||||||
label="Back to Forms"
|
label="Back to Forms"
|
||||||
:to="{ name: 'formList' }"
|
:to="{ name: 'formList' }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import axios from 'boot/axios';
|
import axios from 'boot/axios';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
|
|
||||||
const componentProps = defineProps({
|
const componentProps = defineProps({
|
||||||
id: {
|
id: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
const formTitle = ref('');
|
const formTitle = ref('');
|
||||||
const responses = ref([]);
|
const responses = ref([]);
|
||||||
const columns = ref([]);
|
const columns = ref([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const filterText = ref('');
|
const filterText = ref('');
|
||||||
|
|
||||||
// Fetch both form details (for title and field labels/order) and responses
|
// Fetch both form details (for title and field labels/order) and responses
|
||||||
async function fetchData()
|
async function fetchData()
|
||||||
{
|
{
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
formTitle.value = '';
|
formTitle.value = '';
|
||||||
responses.value = [];
|
responses.value = [];
|
||||||
columns.value = [];
|
columns.value = [];
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Fetch form details first to get the structure
|
// Fetch form details first to get the structure
|
||||||
const formDetailsResponse = await axios.get(`/api/forms/${componentProps.id}`);
|
const formDetailsResponse = await axios.get(`/api/forms/${componentProps.id}`);
|
||||||
const form = formDetailsResponse.data;
|
const form = formDetailsResponse.data;
|
||||||
formTitle.value = form.title;
|
formTitle.value = form.title;
|
||||||
|
|
||||||
// Generate columns based on form fields in correct order
|
// Generate columns based on form fields in correct order
|
||||||
const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }];
|
const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }];
|
||||||
form.categories.forEach(cat =>
|
form.categories.forEach(cat =>
|
||||||
{
|
{
|
||||||
cat.fields.forEach(field =>
|
cat.fields.forEach(field =>
|
||||||
{
|
{
|
||||||
generatedColumns.push({
|
generatedColumns.push({
|
||||||
name: `field_${field.id}`,
|
name: `field_${field.id}`,
|
||||||
label: field.label,
|
label: field.label,
|
||||||
field: row => row.values[field.id]?.value ?? '',
|
field: row => row.values[field.id]?.value ?? '',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
columns.value = generatedColumns;
|
columns.value = generatedColumns;
|
||||||
|
|
||||||
// Add Actions column
|
// Add Actions column
|
||||||
columns.value.push({
|
columns.value.push({
|
||||||
name: 'actions',
|
name: 'actions',
|
||||||
label: 'Actions',
|
label: 'Actions',
|
||||||
field: 'actions',
|
field: 'actions',
|
||||||
align: 'center'
|
align: 'center'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch responses
|
// Fetch responses
|
||||||
const responsesResponse = await axios.get(`/api/forms/${componentProps.id}/responses`);
|
const responsesResponse = await axios.get(`/api/forms/${componentProps.id}/responses`);
|
||||||
responses.value = responsesResponse.data;
|
responses.value = responsesResponse.data;
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error(`Error fetching data for form ${componentProps.id}:`, error);
|
console.error(`Error fetching data for form ${componentProps.id}:`, error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: 'Failed to load form responses.',
|
message: 'Failed to load form responses.',
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed property to match the structure expected by QTable rows
|
// Computed property to match the structure expected by QTable rows
|
||||||
const formattedResponses = computed(() =>
|
const formattedResponses = computed(() =>
|
||||||
{
|
{
|
||||||
return responses.value.map(response =>
|
return responses.value.map(response =>
|
||||||
{
|
{
|
||||||
const row = {
|
const row = {
|
||||||
id: response.id,
|
id: response.id,
|
||||||
submittedAt: response.submittedAt,
|
submittedAt: response.submittedAt,
|
||||||
// Flatten values for direct access by field function in columns
|
// Flatten values for direct access by field function in columns
|
||||||
values: response.values
|
values: response.values
|
||||||
};
|
};
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Function to download a single response as PDF
|
// Function to download a single response as PDF
|
||||||
async function downloadResponsePdf(responseId)
|
async function downloadResponsePdf(responseId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
|
const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
|
||||||
responseType: 'blob', // Important for handling file downloads
|
responseType: 'blob', // Important for handling file downloads
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a URL for the blob
|
// Create a URL for the blob
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|
||||||
// Try to get filename from content-disposition header
|
// Try to get filename from content-disposition header
|
||||||
const contentDisposition = response.headers['content-disposition'];
|
const contentDisposition = response.headers['content-disposition'];
|
||||||
let filename = `response-${responseId}.pdf`; // Default filename
|
let filename = `response-${responseId}.pdf`; // Default filename
|
||||||
if (contentDisposition)
|
if (contentDisposition)
|
||||||
{
|
{
|
||||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
|
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
|
||||||
if (filenameMatch && filenameMatch.length > 1)
|
if (filenameMatch && filenameMatch.length > 1)
|
||||||
{
|
{
|
||||||
filename = filenameMatch[1];
|
filename = filenameMatch[1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
link.setAttribute('download', filename);
|
link.setAttribute('download', filename);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
link.parentNode.removeChild(link);
|
link.parentNode.removeChild(link);
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'positive',
|
color: 'positive',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: `Downloaded ${filename}`,
|
message: `Downloaded ${filename}`,
|
||||||
icon: 'check_circle'
|
icon: 'check_circle'
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error(`Error downloading PDF for response ${responseId}:`, error);
|
console.error(`Error downloading PDF for response ${responseId}:`, error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
message: 'Failed to download PDF.',
|
message: 'Failed to download PDF.',
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchData);
|
onMounted(fetchData);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,54 +1,54 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page class="landing-page column items-center q-pa-md">
|
<q-page class="landing-page column items-center q-pa-md">
|
||||||
<div class="hero text-center q-pa-xl full-width">
|
<div class="hero text-center q-pa-xl full-width">
|
||||||
<h1 class="text-h3 text-weight-bold text-primary q-mb-sm">
|
<h1 class="text-h3 text-weight-bold text-primary q-mb-sm">
|
||||||
Welcome to StylePoint
|
Welcome to StylePoint
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-h6 text-grey-8 q-mb-lg">
|
<p class="text-h6 text-grey-8 q-mb-lg">
|
||||||
The all-in-one tool designed for StyleTech Developers.
|
The all-in-one tool designed for StyleTech Developers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="features q-mt-xl q-pa-md text-center"
|
class="features q-mt-xl q-pa-md text-center"
|
||||||
style="max-width: 800px; width: 100%;"
|
style="max-width: 800px; width: 100%;"
|
||||||
>
|
>
|
||||||
<h2 class="text-h4 text-weight-medium text-secondary q-mb-lg">
|
<h2 class="text-h4 text-weight-medium text-secondary q-mb-lg">
|
||||||
Features
|
Features
|
||||||
</h2>
|
</h2>
|
||||||
<q-list
|
<q-list
|
||||||
bordered
|
bordered
|
||||||
separator
|
separator
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
>
|
>
|
||||||
<q-item
|
<q-item
|
||||||
v-for="(feature, index) in features"
|
v-for="(feature, index) in features"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="q-pa-md"
|
class="q-pa-md"
|
||||||
>
|
>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label class="text-body1">
|
<q-item-label class="text-body1">
|
||||||
{{ feature }}
|
{{ feature }}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</div>
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
|
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
const currentYear = ref(new Date().getFullYear());
|
const currentYear = ref(new Date().getFullYear());
|
||||||
|
|
||||||
const features = ref([
|
const features = ref([
|
||||||
'Auatomated Daily Reports',
|
'Auatomated Daily Reports',
|
||||||
'Deep Mantis Integration',
|
'Deep Mantis Integration',
|
||||||
'Easy Authentication',
|
'Easy Authentication',
|
||||||
'And more..?'
|
'And more..?'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
</script>
|
</script>
|
|
@ -1,130 +1,130 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page class="flex flex-center">
|
<q-page class="flex flex-center">
|
||||||
<q-card style="width: 400px; max-width: 90vw;">
|
<q-card style="width: 400px; max-width: 90vw;">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6">
|
<div class="text-h6">
|
||||||
Login
|
Login
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="username"
|
v-model="username"
|
||||||
label="Username"
|
label="Username"
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
@keyup.enter="handleLogin"
|
@keyup.enter="handleLogin"
|
||||||
:hint="errorMessage ? errorMessage : ''"
|
:hint="errorMessage ? errorMessage : ''"
|
||||||
:rules="[val => !!val || 'Username is required']"
|
:rules="[val => !!val || 'Username is required']"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
label="Login with Passkey"
|
label="Login with Passkey"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="full-width"
|
class="full-width"
|
||||||
@click="handleLogin"
|
@click="handleLogin"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="errorMessage"
|
v-if="errorMessage"
|
||||||
class="text-negative q-mt-md"
|
class="text-negative q-mt-md"
|
||||||
>
|
>
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-actions align="center">
|
<q-card-actions align="center">
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
label="Don't have an account? Register"
|
label="Don't have an account? Register"
|
||||||
to="/register"
|
to="/register"
|
||||||
/>
|
/>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
import axios from 'boot/axios';
|
import axios from 'boot/axios';
|
||||||
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
||||||
|
|
||||||
const username = ref('');
|
const username = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore(); // Use the auth store
|
const authStore = useAuthStore(); // Use the auth store
|
||||||
|
|
||||||
async function handleLogin()
|
async function handleLogin()
|
||||||
{
|
{
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Get options from server
|
// 1. Get options from server
|
||||||
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {
|
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {
|
||||||
username: username.value || undefined, // Send username if provided
|
username: username.value || undefined, // Send username if provided
|
||||||
});
|
});
|
||||||
const options = optionsRes.data;
|
const options = optionsRes.data;
|
||||||
|
|
||||||
// 2. Start authentication ceremony in browser
|
// 2. Start authentication ceremony in browser
|
||||||
const authResp = await startAuthentication(options);
|
const authResp = await startAuthentication(options);
|
||||||
|
|
||||||
// 3. Send response to server for verification
|
// 3. Send response to server for verification
|
||||||
const verificationRes = await axios.post('/api/auth/verify-authentication', {
|
const verificationRes = await axios.post('/api/auth/verify-authentication', {
|
||||||
authenticationResponse: authResp,
|
authenticationResponse: authResp,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (verificationRes.data.verified)
|
if (verificationRes.data.verified)
|
||||||
{
|
{
|
||||||
// Update the auth store on successful login
|
// Update the auth store on successful login
|
||||||
authStore.isAuthenticated = true;
|
authStore.isAuthenticated = true;
|
||||||
authStore.user = verificationRes.data.user;
|
authStore.user = verificationRes.data.user;
|
||||||
authStore.error = null; // Clear any previous errors
|
authStore.error = null; // Clear any previous errors
|
||||||
console.log('Login successful:', verificationRes.data.user);
|
console.log('Login successful:', verificationRes.data.user);
|
||||||
router.push('/'); // Redirect to home page
|
router.push('/'); // Redirect to home page
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
errorMessage.value = 'Authentication failed.';
|
errorMessage.value = 'Authentication failed.';
|
||||||
// Optionally update store state on failure
|
// Optionally update store state on failure
|
||||||
authStore.isAuthenticated = false;
|
authStore.isAuthenticated = false;
|
||||||
authStore.user = null;
|
authStore.user = null;
|
||||||
authStore.error = 'Authentication failed.';
|
authStore.error = 'Authentication failed.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
const message = error.response?.data?.error || error.message || 'An unknown error occurred during login.';
|
const message = error.response?.data?.error || error.message || 'An unknown error occurred during login.';
|
||||||
// Handle specific simplewebauthn errors if needed
|
// Handle specific simplewebauthn errors if needed
|
||||||
if (error.name === 'NotAllowedError')
|
if (error.name === 'NotAllowedError')
|
||||||
{
|
{
|
||||||
errorMessage.value = 'Authentication ceremony was cancelled or timed out.';
|
errorMessage.value = 'Authentication ceremony was cancelled or timed out.';
|
||||||
}
|
}
|
||||||
else if (error.response?.status === 404 && error.response?.data?.error?.includes('User not found'))
|
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.';
|
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'))
|
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.';
|
errorMessage.value = 'No registered passkey found for this user or device. Try registering first.';
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
errorMessage.value = `Login failed: ${message}`;
|
errorMessage.value = `Login failed: ${message}`;
|
||||||
}
|
}
|
||||||
// Optionally update store state on error
|
// Optionally update store state on error
|
||||||
authStore.isAuthenticated = false;
|
authStore.isAuthenticated = false;
|
||||||
authStore.user = null;
|
authStore.user = null;
|
||||||
authStore.error = `Login failed: ${message}`;
|
authStore.error = `Login failed: ${message}`;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,248 +1,248 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<q-card
|
<q-card
|
||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
>
|
>
|
||||||
<q-card-section class="row items-center justify-between">
|
<q-card-section class="row items-center justify-between">
|
||||||
<div class="text-h6">
|
<div class="text-h6">
|
||||||
Mantis Summaries
|
Mantis Summaries
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
<q-btn
|
||||||
label="Generate Today's Summary"
|
label="Generate Today's Summary"
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="generateSummary"
|
@click="generateSummary"
|
||||||
:loading="generating"
|
:loading="generating"
|
||||||
:disable="generating"
|
:disable="generating"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-separator />
|
<q-separator />
|
||||||
|
|
||||||
<q-card-section v-if="generationError">
|
<q-card-section v-if="generationError">
|
||||||
<q-banner
|
<q-banner
|
||||||
inline-actions
|
inline-actions
|
||||||
class="text-white bg-red"
|
class="text-white bg-red"
|
||||||
>
|
>
|
||||||
<template #avatar>
|
<template #avatar>
|
||||||
<q-icon name="error" />
|
<q-icon name="error" />
|
||||||
</template>
|
</template>
|
||||||
{{ generationError }}
|
{{ generationError }}
|
||||||
</q-banner>
|
</q-banner>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section v-if="loading">
|
<q-card-section v-if="loading">
|
||||||
<q-spinner-dots
|
<q-spinner-dots
|
||||||
size="40px"
|
size="40px"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
<span class="q-ml-md">Loading summaries...</span>
|
<span class="q-ml-md">Loading summaries...</span>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section v-if="error && !generationError">
|
<q-card-section v-if="error && !generationError">
|
||||||
<q-banner
|
<q-banner
|
||||||
inline-actions
|
inline-actions
|
||||||
class="text-white bg-red"
|
class="text-white bg-red"
|
||||||
>
|
>
|
||||||
<template #avatar>
|
<template #avatar>
|
||||||
<q-icon name="error" />
|
<q-icon name="error" />
|
||||||
</template>
|
</template>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</q-banner>
|
</q-banner>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-list
|
<q-list
|
||||||
separator
|
separator
|
||||||
v-if="!loading && !error && summaries.length > 0"
|
v-if="!loading && !error && summaries.length > 0"
|
||||||
>
|
>
|
||||||
<q-item
|
<q-item
|
||||||
v-for="summary in summaries"
|
v-for="summary in summaries"
|
||||||
:key="summary.id"
|
:key="summary.id"
|
||||||
>
|
>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label class="text-weight-bold">
|
<q-item-label class="text-weight-bold">
|
||||||
{{ formatDate(summary.summaryDate) }}
|
{{ formatDate(summary.summaryDate) }}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
<q-item-label caption>
|
<q-item-label caption>
|
||||||
Generated: {{ formatDateTime(summary.generatedAt) }}
|
Generated: {{ formatDateTime(summary.generatedAt) }}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
<q-item-label
|
<q-item-label
|
||||||
class="q-mt-sm markdown-content"
|
class="q-mt-sm markdown-content"
|
||||||
>
|
>
|
||||||
<div v-html="parseMarkdown(summary.summaryText)" />
|
<div v-html="parseMarkdown(summary.summaryText)" />
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
|
|
||||||
<q-card-section
|
<q-card-section
|
||||||
v-if="totalPages > 1"
|
v-if="totalPages > 1"
|
||||||
class="flex flex-center q-mt-md"
|
class="flex flex-center q-mt-md"
|
||||||
>
|
>
|
||||||
<q-pagination
|
<q-pagination
|
||||||
v-model="currentPage"
|
v-model="currentPage"
|
||||||
:max="totalPages"
|
:max="totalPages"
|
||||||
@update:model-value="fetchSummaries"
|
@update:model-value="fetchSummaries"
|
||||||
direction-links
|
direction-links
|
||||||
flat
|
flat
|
||||||
color="primary"
|
color="primary"
|
||||||
active-color="primary"
|
active-color="primary"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section v-if="!loading && !error && summaries.length === 0">
|
<q-card-section v-if="!loading && !error && summaries.length === 0">
|
||||||
<div class="text-center text-grey">
|
<div class="text-center text-grey">
|
||||||
No summaries found.
|
No summaries found.
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { date, useQuasar } from 'quasar'; // Import useQuasar
|
import { date, useQuasar } from 'quasar'; // Import useQuasar
|
||||||
import axios from 'boot/axios';
|
import axios from 'boot/axios';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
const $q = useQuasar(); // Initialize Quasar plugin usage
|
const $q = useQuasar(); // Initialize Quasar plugin usage
|
||||||
const summaries = ref([]);
|
const summaries = ref([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
const generating = ref(false); // State for generation button
|
const generating = ref(false); // State for generation button
|
||||||
const generationError = ref(null); // State for generation error
|
const generationError = ref(null); // State for generation error
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const itemsPerPage = ref(10); // Or your desired page size
|
const itemsPerPage = ref(10); // Or your desired page size
|
||||||
const totalItems = ref(0);
|
const totalItems = ref(0);
|
||||||
|
|
||||||
// Create a custom renderer
|
// Create a custom renderer
|
||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
const linkRenderer = renderer.link;
|
const linkRenderer = renderer.link;
|
||||||
renderer.link = (href, title, text) =>
|
renderer.link = (href, title, text) =>
|
||||||
{
|
{
|
||||||
const html = linkRenderer.call(renderer, href, title, text);
|
const html = linkRenderer.call(renderer, href, title, text);
|
||||||
// Add target="_blank" to the link
|
// Add target="_blank" to the link
|
||||||
return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
|
return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSummaries = async(page = 1) =>
|
const fetchSummaries = async(page = 1) =>
|
||||||
{
|
{
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await axios.get('/api/mantis-summaries', {
|
const response = await axios.get('/api/mantis-summaries', {
|
||||||
params: {
|
params: {
|
||||||
page: page,
|
page: page,
|
||||||
limit: itemsPerPage.value
|
limit: itemsPerPage.value
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
summaries.value = response.data.summaries;
|
summaries.value = response.data.summaries;
|
||||||
totalItems.value = response.data.total;
|
totalItems.value = response.data.total;
|
||||||
currentPage.value = page;
|
currentPage.value = page;
|
||||||
}
|
}
|
||||||
catch (err)
|
catch (err)
|
||||||
{
|
{
|
||||||
console.error('Error fetching Mantis summaries:', err);
|
console.error('Error fetching Mantis summaries:', err);
|
||||||
error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.';
|
error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.';
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateSummary = async() =>
|
const generateSummary = async() =>
|
||||||
{
|
{
|
||||||
generating.value = true;
|
generating.value = true;
|
||||||
generationError.value = null;
|
generationError.value = null;
|
||||||
error.value = null; // Clear previous loading errors
|
error.value = null; // Clear previous loading errors
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await axios.post('/api/mantis-summaries/generate');
|
await axios.post('/api/mantis-summaries/generate');
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'positive',
|
color: 'positive',
|
||||||
icon: 'check_circle',
|
icon: 'check_circle',
|
||||||
message: 'Summary generation started successfully. It may take a few moments to appear.',
|
message: 'Summary generation started successfully. It may take a few moments to appear.',
|
||||||
});
|
});
|
||||||
// Optionally, refresh the list after a short delay or immediately
|
// Optionally, refresh the list after a short delay or immediately
|
||||||
// Consider that generation might be async on the backend
|
// Consider that generation might be async on the backend
|
||||||
setTimeout(() => fetchSummaries(1), 3000); // Refresh after 3 seconds
|
setTimeout(() => fetchSummaries(1), 3000); // Refresh after 3 seconds
|
||||||
}
|
}
|
||||||
catch (err)
|
catch (err)
|
||||||
{
|
{
|
||||||
console.error('Error generating Mantis summary:', err);
|
console.error('Error generating Mantis summary:', err);
|
||||||
generationError.value = err.response?.data?.error || 'Failed to start summary generation.';
|
generationError.value = err.response?.data?.error || 'Failed to start summary generation.';
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
message: generationError.value,
|
message: generationError.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
generating.value = false;
|
generating.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString) =>
|
const formatDate = (dateString) =>
|
||||||
{
|
{
|
||||||
// Assuming dateString is YYYY-MM-DD
|
// Assuming dateString is YYYY-MM-DD
|
||||||
return date.formatDate(dateString + 'T00:00:00', 'DD MMMM YYYY');
|
return date.formatDate(dateString + 'T00:00:00', 'DD MMMM YYYY');
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateTime = (dateTimeString) =>
|
const formatDateTime = (dateTimeString) =>
|
||||||
{
|
{
|
||||||
return date.formatDate(dateTimeString, 'DD MMMM YYYY HH:mm');
|
return date.formatDate(dateTimeString, 'DD MMMM YYYY HH:mm');
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseMarkdown = (markdownText) =>
|
const parseMarkdown = (markdownText) =>
|
||||||
{
|
{
|
||||||
if (!markdownText) return '';
|
if (!markdownText) return '';
|
||||||
// Use the custom renderer with marked
|
// Use the custom renderer with marked
|
||||||
return marked(markdownText, { renderer });
|
return marked(markdownText, { renderer });
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPages = computed(() =>
|
const totalPages = computed(() =>
|
||||||
{
|
{
|
||||||
return Math.ceil(totalItems.value / itemsPerPage.value);
|
return Math.ceil(totalItems.value / itemsPerPage.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() =>
|
onMounted(() =>
|
||||||
{
|
{
|
||||||
fetchSummaries(currentPage.value);
|
fetchSummaries(currentPage.value);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.markdown-content :deep(table) {
|
.markdown-content :deep(table) {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :deep(th),
|
.markdown-content :deep(th),
|
||||||
.markdown-content :deep(td) {
|
.markdown-content :deep(td) {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :deep(th) {
|
.markdown-content :deep(th) {
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :deep(a) {
|
.markdown-content :deep(a) {
|
||||||
color: var(--q-primary);
|
color: var(--q-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :deep(a:hover) {
|
.markdown-content :deep(a:hover) {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add any specific styles if needed */
|
/* Add any specific styles if needed */
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,371 +1,371 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<div class="q-mb-md row justify-between items-center">
|
<div class="q-mb-md row justify-between items-center">
|
||||||
<div class="text-h4">
|
<div class="text-h4">
|
||||||
Passkey Management
|
Passkey Management
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<q-btn
|
<q-btn
|
||||||
label="Identify Passkey"
|
label="Identify Passkey"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
class="q-mx-md q-mt-md"
|
class="q-mx-md q-mt-md"
|
||||||
@click="handleIdentify"
|
@click="handleIdentify"
|
||||||
:loading="identifyLoading"
|
:loading="identifyLoading"
|
||||||
:disable="identifyLoading || !isLoggedIn"
|
:disable="identifyLoading || !isLoggedIn"
|
||||||
outline
|
outline
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
label="Register New Passkey"
|
label="Register New Passkey"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="q-mx-md q-mt-md"
|
class="q-mx-md q-mt-md"
|
||||||
@click="handleRegister"
|
@click="handleRegister"
|
||||||
:loading="registerLoading"
|
:loading="registerLoading"
|
||||||
:disable="registerLoading || !isLoggedIn"
|
:disable="registerLoading || !isLoggedIn"
|
||||||
outline
|
outline
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Passkey List Section -->
|
<!-- Passkey List Section -->
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h5>Your Registered Passkeys</h5>
|
<h5>Your Registered Passkeys</h5>
|
||||||
<q-list
|
<q-list
|
||||||
bordered
|
bordered
|
||||||
separator
|
separator
|
||||||
v-if="passkeys.length > 0 && !fetchLoading"
|
v-if="passkeys.length > 0 && !fetchLoading"
|
||||||
>
|
>
|
||||||
<q-item v-if="registerSuccessMessage || registerErrorMessage">
|
<q-item v-if="registerSuccessMessage || registerErrorMessage">
|
||||||
<div
|
<div
|
||||||
v-if="registerSuccessMessage"
|
v-if="registerSuccessMessage"
|
||||||
class="text-positive q-mt-md"
|
class="text-positive q-mt-md"
|
||||||
>
|
>
|
||||||
{{ registerSuccessMessage }}
|
{{ registerSuccessMessage }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="registerErrorMessage"
|
v-if="registerErrorMessage"
|
||||||
class="text-negative q-mt-md"
|
class="text-negative q-mt-md"
|
||||||
>
|
>
|
||||||
{{ registerErrorMessage }}
|
{{ registerErrorMessage }}
|
||||||
</div>
|
</div>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item
|
<q-item
|
||||||
v-for="passkey in passkeys"
|
v-for="passkey in passkeys"
|
||||||
:key="passkey.credentialID"
|
:key="passkey.credentialID"
|
||||||
:class="{ 'bg-info text-h6': identifiedPasskeyId === passkey.credentialID }"
|
:class="{ 'bg-info text-h6': identifiedPasskeyId === passkey.credentialID }"
|
||||||
>
|
>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label>
|
<q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label>
|
||||||
<q-item-label
|
<q-item-label
|
||||||
caption
|
caption
|
||||||
v-if="identifiedPasskeyId === passkey.credentialID"
|
v-if="identifiedPasskeyId === passkey.credentialID"
|
||||||
>
|
>
|
||||||
Verified just now!
|
Verified just now!
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
<!-- <q-item-label caption>Registered: {{ new Date(passkey.createdAt).toLocaleDateString() }}</q-item-label> -->
|
<!-- <q-item-label caption>Registered: {{ new Date(passkey.createdAt).toLocaleDateString() }}</q-item-label> -->
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
||||||
<q-item-section
|
<q-item-section
|
||||||
side
|
side
|
||||||
class="row no-wrap items-center"
|
class="row no-wrap items-center"
|
||||||
>
|
>
|
||||||
<!-- Delete Button -->
|
<!-- Delete Button -->
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
round
|
round
|
||||||
color="negative"
|
color="negative"
|
||||||
icon="delete"
|
icon="delete"
|
||||||
@click="handleDelete(passkey.credentialID)"
|
@click="handleDelete(passkey.credentialID)"
|
||||||
:loading="deleteLoading === passkey.credentialID"
|
:loading="deleteLoading === passkey.credentialID"
|
||||||
:disable="!!deleteLoading || !!identifyLoading"
|
:disable="!!deleteLoading || !!identifyLoading"
|
||||||
/>
|
/>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
<div
|
<div
|
||||||
v-else-if="fetchLoading"
|
v-else-if="fetchLoading"
|
||||||
class="q-mt-md"
|
class="q-mt-md"
|
||||||
>
|
>
|
||||||
Loading passkeys...
|
Loading passkeys...
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="q-mt-md"
|
class="q-mt-md"
|
||||||
>
|
>
|
||||||
You have no passkeys registered yet.
|
You have no passkeys registered yet.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="fetchErrorMessage"
|
v-if="fetchErrorMessage"
|
||||||
class="text-negative q-mt-md"
|
class="text-negative q-mt-md"
|
||||||
>
|
>
|
||||||
{{ fetchErrorMessage }}
|
{{ fetchErrorMessage }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="deleteSuccessMessage"
|
v-if="deleteSuccessMessage"
|
||||||
class="text-positive q-mt-md"
|
class="text-positive q-mt-md"
|
||||||
>
|
>
|
||||||
{{ deleteSuccessMessage }}
|
{{ deleteSuccessMessage }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="deleteErrorMessage"
|
v-if="deleteErrorMessage"
|
||||||
class="text-negative q-mt-md"
|
class="text-negative q-mt-md"
|
||||||
>
|
>
|
||||||
{{ deleteErrorMessage }}
|
{{ deleteErrorMessage }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="identifyErrorMessage"
|
v-if="identifyErrorMessage"
|
||||||
class="text-negative q-mt-md"
|
class="text-negative q-mt-md"
|
||||||
>
|
>
|
||||||
{{ identifyErrorMessage }}
|
{{ identifyErrorMessage }}
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; // Import startAuthentication
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; // Import startAuthentication
|
||||||
import axios from 'boot/axios';
|
import axios from 'boot/axios';
|
||||||
import { useAuthStore } from 'stores/auth';
|
import { useAuthStore } from 'stores/auth';
|
||||||
|
|
||||||
const registerLoading = ref(false);
|
const registerLoading = ref(false);
|
||||||
const registerErrorMessage = ref('');
|
const registerErrorMessage = ref('');
|
||||||
const registerSuccessMessage = ref('');
|
const registerSuccessMessage = ref('');
|
||||||
const fetchLoading = ref(false);
|
const fetchLoading = ref(false);
|
||||||
const fetchErrorMessage = ref('');
|
const fetchErrorMessage = ref('');
|
||||||
const deleteLoading = ref(null);
|
const deleteLoading = ref(null);
|
||||||
const deleteErrorMessage = ref('');
|
const deleteErrorMessage = ref('');
|
||||||
const deleteSuccessMessage = ref('');
|
const deleteSuccessMessage = ref('');
|
||||||
const identifyLoading = ref(null); // Store the ID of the passkey being identified
|
const identifyLoading = ref(null); // Store the ID of the passkey being identified
|
||||||
const identifyErrorMessage = ref('');
|
const identifyErrorMessage = ref('');
|
||||||
const identifiedPasskeyId = ref(null); // Store the ID of the successfully identified passkey
|
const identifiedPasskeyId = ref(null); // Store the ID of the successfully identified passkey
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const passkeys = ref([]); // To store the list of passkeys
|
const passkeys = ref([]); // To store the list of passkeys
|
||||||
|
|
||||||
// Computed properties to get state from the store
|
// Computed properties to get state from the store
|
||||||
const isLoggedIn = computed(() => authStore.isAuthenticated);
|
const isLoggedIn = computed(() => authStore.isAuthenticated);
|
||||||
const username = computed(() => authStore.user?.username);
|
const username = computed(() => authStore.user?.username);
|
||||||
|
|
||||||
// Fetch existing passkeys
|
// Fetch existing passkeys
|
||||||
async function fetchPasskeys()
|
async function fetchPasskeys()
|
||||||
{
|
{
|
||||||
if (!isLoggedIn.value) return;
|
if (!isLoggedIn.value) return;
|
||||||
fetchLoading.value = true;
|
fetchLoading.value = true;
|
||||||
fetchErrorMessage.value = '';
|
fetchErrorMessage.value = '';
|
||||||
deleteSuccessMessage.value = ''; // Clear delete messages on refresh
|
deleteSuccessMessage.value = ''; // Clear delete messages on refresh
|
||||||
deleteErrorMessage.value = '';
|
deleteErrorMessage.value = '';
|
||||||
identifyErrorMessage.value = ''; // Clear identify message
|
identifyErrorMessage.value = ''; // Clear identify message
|
||||||
identifiedPasskeyId.value = null; // Clear identified key
|
identifiedPasskeyId.value = null; // Clear identified key
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await axios.get('/api/auth/passkeys');
|
const response = await axios.get('/api/auth/passkeys');
|
||||||
passkeys.value = response.data || [];
|
passkeys.value = response.data || [];
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error fetching passkeys:', error);
|
console.error('Error fetching passkeys:', error);
|
||||||
fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.';
|
fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.';
|
||||||
passkeys.value = []; // Clear passkeys on error
|
passkeys.value = []; // Clear passkeys on error
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
fetchLoading.value = false;
|
fetchLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check auth status and fetch passkeys on component mount
|
// Check auth status and fetch passkeys on component mount
|
||||||
onMounted(async() =>
|
onMounted(async() =>
|
||||||
{
|
{
|
||||||
let initialAuthError = '';
|
let initialAuthError = '';
|
||||||
if (!authStore.isAuthenticated)
|
if (!authStore.isAuthenticated)
|
||||||
{
|
{
|
||||||
await authStore.checkAuthStatus();
|
await authStore.checkAuthStatus();
|
||||||
if (authStore.error)
|
if (authStore.error)
|
||||||
{
|
{
|
||||||
initialAuthError = `Authentication error: ${authStore.error}`;
|
initialAuthError = `Authentication error: ${authStore.error}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isLoggedIn.value)
|
if (!isLoggedIn.value)
|
||||||
{
|
{
|
||||||
// Use register error message ref for consistency if login is required first
|
// Use register error message ref for consistency if login is required first
|
||||||
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
|
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
fetchPasskeys(); // Fetch passkeys if logged in
|
fetchPasskeys(); // Fetch passkeys if logged in
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleRegister()
|
async function handleRegister()
|
||||||
{
|
{
|
||||||
if (!isLoggedIn.value || !username.value)
|
if (!isLoggedIn.value || !username.value)
|
||||||
{
|
{
|
||||||
registerErrorMessage.value = 'User not authenticated.';
|
registerErrorMessage.value = 'User not authenticated.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
registerLoading.value = true;
|
registerLoading.value = true;
|
||||||
registerErrorMessage.value = '';
|
registerErrorMessage.value = '';
|
||||||
registerSuccessMessage.value = '';
|
registerSuccessMessage.value = '';
|
||||||
deleteSuccessMessage.value = ''; // Clear other messages
|
deleteSuccessMessage.value = ''; // Clear other messages
|
||||||
deleteErrorMessage.value = '';
|
deleteErrorMessage.value = '';
|
||||||
identifyErrorMessage.value = '';
|
identifyErrorMessage.value = '';
|
||||||
identifiedPasskeyId.value = null;
|
identifiedPasskeyId.value = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Get options from server
|
// 1. Get options from server
|
||||||
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
|
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
|
||||||
username: username.value, // Use username from store
|
username: username.value, // Use username from store
|
||||||
});
|
});
|
||||||
const options = optionsRes.data;
|
const options = optionsRes.data;
|
||||||
|
|
||||||
// 2. Start registration ceremony in browser
|
// 2. Start registration ceremony in browser
|
||||||
const regResp = await startRegistration(options);
|
const regResp = await startRegistration(options);
|
||||||
|
|
||||||
// 3. Send response to server for verification
|
// 3. Send response to server for verification
|
||||||
const verificationRes = await axios.post('/api/auth/verify-registration', {
|
const verificationRes = await axios.post('/api/auth/verify-registration', {
|
||||||
registrationResponse: regResp,
|
registrationResponse: regResp,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (verificationRes.data.verified)
|
if (verificationRes.data.verified)
|
||||||
{
|
{
|
||||||
registerSuccessMessage.value = 'New passkey registered successfully!';
|
registerSuccessMessage.value = 'New passkey registered successfully!';
|
||||||
fetchPasskeys(); // Refresh the list of passkeys
|
fetchPasskeys(); // Refresh the list of passkeys
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
registerErrorMessage.value = 'Passkey verification failed.';
|
registerErrorMessage.value = 'Passkey verification failed.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Registration error:', error);
|
console.error('Registration error:', error);
|
||||||
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
|
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
|
||||||
// Handle specific simplewebauthn errors
|
// Handle specific simplewebauthn errors
|
||||||
if (error.name === 'InvalidStateError')
|
if (error.name === 'InvalidStateError')
|
||||||
{
|
{
|
||||||
registerErrorMessage.value = 'Authenticator may already be registered.';
|
registerErrorMessage.value = 'Authenticator may already be registered.';
|
||||||
}
|
}
|
||||||
else if (error.name === 'NotAllowedError')
|
else if (error.name === 'NotAllowedError')
|
||||||
{
|
{
|
||||||
registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.';
|
registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.';
|
||||||
}
|
}
|
||||||
else if (error.response?.status === 409)
|
else if (error.response?.status === 409)
|
||||||
{
|
{
|
||||||
registerErrorMessage.value = 'This passkey seems to be registered already.';
|
registerErrorMessage.value = 'This passkey seems to be registered already.';
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
registerErrorMessage.value = `Registration failed: ${message}`;
|
registerErrorMessage.value = `Registration failed: ${message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
registerLoading.value = false;
|
registerLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Handle deleting a passkey
|
// Handle deleting a passkey
|
||||||
async function handleDelete(credentialID)
|
async function handleDelete(credentialID)
|
||||||
{
|
{
|
||||||
if (!credentialID) return;
|
if (!credentialID) return;
|
||||||
|
|
||||||
// Optional: Add a confirmation dialog here
|
// Optional: Add a confirmation dialog here
|
||||||
// if (!confirm('Are you sure you want to delete this passkey?')) {
|
// if (!confirm('Are you sure you want to delete this passkey?')) {
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
deleteLoading.value = credentialID; // Set loading state for the specific button
|
deleteLoading.value = credentialID; // Set loading state for the specific button
|
||||||
deleteErrorMessage.value = '';
|
deleteErrorMessage.value = '';
|
||||||
deleteSuccessMessage.value = '';
|
deleteSuccessMessage.value = '';
|
||||||
registerSuccessMessage.value = ''; // Clear other messages
|
registerSuccessMessage.value = ''; // Clear other messages
|
||||||
registerErrorMessage.value = '';
|
registerErrorMessage.value = '';
|
||||||
identifyErrorMessage.value = '';
|
identifyErrorMessage.value = '';
|
||||||
identifiedPasskeyId.value = null;
|
identifiedPasskeyId.value = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await axios.delete(`/api/auth/passkeys/${credentialID}`);
|
await axios.delete(`/api/auth/passkeys/${credentialID}`);
|
||||||
deleteSuccessMessage.value = 'Passkey deleted successfully.';
|
deleteSuccessMessage.value = 'Passkey deleted successfully.';
|
||||||
fetchPasskeys(); // Refresh the list
|
fetchPasskeys(); // Refresh the list
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error deleting passkey:', error);
|
console.error('Error deleting passkey:', error);
|
||||||
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
|
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
deleteLoading.value = null; // Clear loading state
|
deleteLoading.value = null; // Clear loading state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle identifying a passkey
|
// Handle identifying a passkey
|
||||||
async function handleIdentify()
|
async function handleIdentify()
|
||||||
{
|
{
|
||||||
if (!isLoggedIn.value)
|
if (!isLoggedIn.value)
|
||||||
{
|
{
|
||||||
identifyErrorMessage.value = 'You must be logged in.';
|
identifyErrorMessage.value = 'You must be logged in.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
identifyLoading.value = true;
|
identifyLoading.value = true;
|
||||||
identifyErrorMessage.value = '';
|
identifyErrorMessage.value = '';
|
||||||
identifiedPasskeyId.value = null; // Reset identified key
|
identifiedPasskeyId.value = null; // Reset identified key
|
||||||
// Clear other messages
|
// Clear other messages
|
||||||
registerSuccessMessage.value = '';
|
registerSuccessMessage.value = '';
|
||||||
registerErrorMessage.value = '';
|
registerErrorMessage.value = '';
|
||||||
deleteSuccessMessage.value = '';
|
deleteSuccessMessage.value = '';
|
||||||
deleteErrorMessage.value = '';
|
deleteErrorMessage.value = '';
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Get authentication options from the server
|
// 1. Get authentication options from the server
|
||||||
// We don't need to send username as the server should use the session
|
// We don't need to send username as the server should use the session
|
||||||
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {}); // Send empty body
|
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {}); // Send empty body
|
||||||
const options = optionsRes.data;
|
const options = optionsRes.data;
|
||||||
|
|
||||||
// Optionally filter options to only allow the specific key if needed, but usually not necessary for identification
|
// 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);
|
// options.allowCredentials = options.allowCredentials?.filter(cred => cred.id === credentialIDToIdentify);
|
||||||
|
|
||||||
// 2. Start authentication ceremony in the browser
|
// 2. Start authentication ceremony in the browser
|
||||||
const authResp = await startAuthentication(options);
|
const authResp = await startAuthentication(options);
|
||||||
|
|
||||||
// 3. If successful, the response contains the ID of the key used
|
// 3. If successful, the response contains the ID of the key used
|
||||||
identifiedPasskeyId.value = authResp.id;
|
identifiedPasskeyId.value = authResp.id;
|
||||||
console.log('Identified Passkey ID:', identifiedPasskeyId.value);
|
console.log('Identified Passkey ID:', identifiedPasskeyId.value);
|
||||||
|
|
||||||
// Optional: Add a small delay before clearing the highlight
|
// Optional: Add a small delay before clearing the highlight
|
||||||
setTimeout(() =>
|
setTimeout(() =>
|
||||||
{
|
{
|
||||||
// Only clear if it's still the same identified key
|
// Only clear if it's still the same identified key
|
||||||
if (identifiedPasskeyId.value === authResp.id)
|
if (identifiedPasskeyId.value === authResp.id)
|
||||||
{
|
{
|
||||||
identifiedPasskeyId.value = null;
|
identifiedPasskeyId.value = null;
|
||||||
}
|
}
|
||||||
}, 5000); // Clear highlight after 5 seconds
|
}, 5000); // Clear highlight after 5 seconds
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Identification error:', error);
|
console.error('Identification error:', error);
|
||||||
identifiedPasskeyId.value = null;
|
identifiedPasskeyId.value = null;
|
||||||
if (error.name === 'NotAllowedError')
|
if (error.name === 'NotAllowedError')
|
||||||
{
|
{
|
||||||
identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.';
|
identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.';
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.';
|
identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
identifyLoading.value = null; // Clear loading state
|
identifyLoading.value = null; // Clear loading state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
|
@ -1,179 +1,179 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page class="flex flex-center">
|
<q-page class="flex flex-center">
|
||||||
<q-card style="width: 400px; max-width: 90vw;">
|
<q-card style="width: 400px; max-width: 90vw;">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<!-- Update title based on login status from store -->
|
<!-- Update title based on login status from store -->
|
||||||
<div class="text-h6">
|
<div class="text-h6">
|
||||||
{{ isLoggedIn ? 'Register New Passkey' : 'Register Passkey' }}
|
{{ isLoggedIn ? 'Register New Passkey' : 'Register Passkey' }}
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="username"
|
v-model="username"
|
||||||
label="Username"
|
label="Username"
|
||||||
outlined
|
outlined
|
||||||
dense
|
dense
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
:rules="[val => !!val || 'Username is required']"
|
:rules="[val => !!val || 'Username is required']"
|
||||||
@keyup.enter="handleRegister"
|
@keyup.enter="handleRegister"
|
||||||
:disable="isLoggedIn"
|
:disable="isLoggedIn"
|
||||||
:hint="isLoggedIn ? 'Registering a new passkey for your current account.' : ''"
|
:hint="isLoggedIn ? 'Registering a new passkey for your current account.' : ''"
|
||||||
:readonly="isLoggedIn"
|
:readonly="isLoggedIn"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
:label="isLoggedIn ? 'Register New Passkey' : 'Register Passkey'"
|
:label="isLoggedIn ? 'Register New Passkey' : 'Register Passkey'"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="full-width"
|
class="full-width"
|
||||||
@click="handleRegister"
|
@click="handleRegister"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:disable="loading || (!username && !isLoggedIn)"
|
:disable="loading || (!username && !isLoggedIn)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="successMessage"
|
v-if="successMessage"
|
||||||
class="text-positive q-mt-md"
|
class="text-positive q-mt-md"
|
||||||
>
|
>
|
||||||
{{ successMessage }}
|
{{ successMessage }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="errorMessage"
|
v-if="errorMessage"
|
||||||
class="text-negative q-mt-md"
|
class="text-negative q-mt-md"
|
||||||
>
|
>
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-actions align="center">
|
<q-card-actions align="center">
|
||||||
<!-- Hide login link if already logged in based on store state -->
|
<!-- Hide login link if already logged in based on store state -->
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="!isLoggedIn"
|
v-if="!isLoggedIn"
|
||||||
flat
|
flat
|
||||||
label="Already have an account? Login"
|
label="Already have an account? Login"
|
||||||
to="/login"
|
to="/login"
|
||||||
/>
|
/>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'; // Import computed
|
import { ref, onMounted, computed } from 'vue'; // Import computed
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
import axios from 'boot/axios';
|
import axios from 'boot/axios';
|
||||||
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const successMessage = ref('');
|
const successMessage = ref('');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore(); // Use the auth store
|
const authStore = useAuthStore(); // Use the auth store
|
||||||
|
|
||||||
// Computed properties to get state from the store
|
// Computed properties to get state from the store
|
||||||
const isLoggedIn = computed(() => authStore.isAuthenticated);
|
const isLoggedIn = computed(() => authStore.isAuthenticated);
|
||||||
|
|
||||||
const username = ref(''); // Local ref for username input
|
const username = ref(''); // Local ref for username input
|
||||||
|
|
||||||
// Check auth status on component mount using the store action
|
// Check auth status on component mount using the store action
|
||||||
onMounted(async() =>
|
onMounted(async() =>
|
||||||
{
|
{
|
||||||
if (!authStore.isAuthenticated)
|
if (!authStore.isAuthenticated)
|
||||||
{
|
{
|
||||||
await authStore.checkAuthStatus();
|
await authStore.checkAuthStatus();
|
||||||
if (authStore.error)
|
if (authStore.error)
|
||||||
{
|
{
|
||||||
errorMessage.value = authStore.error;
|
errorMessage.value = authStore.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoggedIn.value)
|
if (!isLoggedIn.value)
|
||||||
{
|
{
|
||||||
username.value = ''; // Clear username if not logged in
|
username.value = ''; // Clear username if not logged in
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
username.value = authStore.user?.username || ''; // Use username from store if logged in
|
username.value = authStore.user?.username || ''; // Use username from store if logged in
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleRegister()
|
async function handleRegister()
|
||||||
{
|
{
|
||||||
const currentUsername = isLoggedIn.value ? authStore.user?.username : username.value;
|
const currentUsername = isLoggedIn.value ? authStore.user?.username : username.value;
|
||||||
if (!currentUsername)
|
if (!currentUsername)
|
||||||
{
|
{
|
||||||
errorMessage.value = 'Username is missing.';
|
errorMessage.value = 'Username is missing.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
successMessage.value = '';
|
successMessage.value = '';
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Get options from server
|
// 1. Get options from server
|
||||||
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
|
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
|
||||||
username: currentUsername, // Use username from store
|
username: currentUsername, // Use username from store
|
||||||
});
|
});
|
||||||
const options = optionsRes.data;
|
const options = optionsRes.data;
|
||||||
|
|
||||||
// 2. Start registration ceremony in browser
|
// 2. Start registration ceremony in browser
|
||||||
const regResp = await startRegistration(options);
|
const regResp = await startRegistration(options);
|
||||||
|
|
||||||
// 3. Send response to server for verification
|
// 3. Send response to server for verification
|
||||||
const verificationRes = await axios.post('/api/auth/verify-registration', {
|
const verificationRes = await axios.post('/api/auth/verify-registration', {
|
||||||
registrationResponse: regResp,
|
registrationResponse: regResp,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (verificationRes.data.verified)
|
if (verificationRes.data.verified)
|
||||||
{
|
{
|
||||||
// Adjust success message based on login state
|
// Adjust success message based on login state
|
||||||
successMessage.value = isLoggedIn.value
|
successMessage.value = isLoggedIn.value
|
||||||
? 'New passkey registered successfully!'
|
? 'New passkey registered successfully!'
|
||||||
: 'Registration successful! Redirecting to login...';
|
: 'Registration successful! Redirecting to login...';
|
||||||
if (!isLoggedIn.value)
|
if (!isLoggedIn.value)
|
||||||
{
|
{
|
||||||
// Redirect to login page only if they weren't logged in
|
// Redirect to login page only if they weren't logged in
|
||||||
setTimeout(() =>
|
setTimeout(() =>
|
||||||
{
|
{
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Maybe redirect to a profile page or dashboard if already logged in
|
// Maybe redirect to a profile page or dashboard if already logged in
|
||||||
// setTimeout(() => { router.push('/dashboard'); }, 2000);
|
// setTimeout(() => { router.push('/dashboard'); }, 2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
errorMessage.value = 'Registration failed.';
|
errorMessage.value = 'Registration failed.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Registration error:', error);
|
console.error('Registration error:', error);
|
||||||
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
|
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
|
||||||
// Handle specific simplewebauthn errors
|
// Handle specific simplewebauthn errors
|
||||||
if (error.name === 'InvalidStateError')
|
if (error.name === 'InvalidStateError')
|
||||||
{
|
{
|
||||||
errorMessage.value = 'Authenticator already registered. Try logging in instead.';
|
errorMessage.value = 'Authenticator already registered. Try logging in instead.';
|
||||||
}
|
}
|
||||||
else if (error.name === 'NotAllowedError')
|
else if (error.name === 'NotAllowedError')
|
||||||
{
|
{
|
||||||
errorMessage.value = 'Registration ceremony was cancelled or timed out.';
|
errorMessage.value = 'Registration ceremony was cancelled or timed out.';
|
||||||
}
|
}
|
||||||
else if (error.response?.status === 409)
|
else if (error.response?.status === 409)
|
||||||
{
|
{
|
||||||
errorMessage.value = 'This passkey seems to be registered already.';
|
errorMessage.value = 'This passkey seems to be registered already.';
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
errorMessage.value = `Registration failed: ${message}`;
|
errorMessage.value = `Registration failed: ${message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,202 +1,202 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<div
|
<div
|
||||||
class="q-gutter-md"
|
class="q-gutter-md"
|
||||||
style="max-width: 800px; margin: auto;"
|
style="max-width: 800px; margin: auto;"
|
||||||
>
|
>
|
||||||
<h5 class="q-mt-none q-mb-md">
|
<h5 class="q-mt-none q-mb-md">
|
||||||
Settings
|
Settings
|
||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
<q-card
|
<q-card
|
||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
>
|
>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6">
|
<div class="text-h6">
|
||||||
Mantis Summary Prompt
|
Mantis Summary Prompt
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption text-grey q-mb-sm">
|
<div class="text-caption text-grey q-mb-sm">
|
||||||
Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders.
|
Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders.
|
||||||
</div>
|
</div>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="mantisPrompt"
|
v-model="mantisPrompt"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
filled
|
filled
|
||||||
autogrow
|
autogrow
|
||||||
label="Mantis Prompt"
|
label="Mantis Prompt"
|
||||||
:loading="loadingPrompt"
|
:loading="loadingPrompt"
|
||||||
:disable="savingPrompt"
|
:disable="savingPrompt"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn
|
<q-btn
|
||||||
label="Save Prompt"
|
label="Save Prompt"
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="saveMantisPrompt"
|
@click="saveMantisPrompt"
|
||||||
:loading="savingPrompt"
|
:loading="savingPrompt"
|
||||||
:disable="!mantisPrompt || loadingPrompt"
|
:disable="!mantisPrompt || loadingPrompt"
|
||||||
/>
|
/>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<q-card
|
<q-card
|
||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
>
|
>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6">
|
<div class="text-h6">
|
||||||
Email Summary Prompt
|
Email Summary Prompt
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption text-grey q-mb-sm">
|
<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.
|
Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array.
|
||||||
</div>
|
</div>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="emailPrompt"
|
v-model="emailPrompt"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
filled
|
filled
|
||||||
autogrow
|
autogrow
|
||||||
label="Email Prompt"
|
label="Email Prompt"
|
||||||
:loading="loadingEmailPrompt"
|
:loading="loadingEmailPrompt"
|
||||||
:disable="savingEmailPrompt"
|
:disable="savingEmailPrompt"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn
|
<q-btn
|
||||||
label="Save Prompt"
|
label="Save Prompt"
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="saveEmailPrompt"
|
@click="saveEmailPrompt"
|
||||||
:loading="savingEmailPrompt"
|
:loading="savingEmailPrompt"
|
||||||
:disable="!emailPrompt || loadingEmailPrompt"
|
:disable="!emailPrompt || loadingEmailPrompt"
|
||||||
/>
|
/>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import axios from 'boot/axios';
|
import axios from 'boot/axios';
|
||||||
|
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
|
|
||||||
const mantisPrompt = ref('');
|
const mantisPrompt = ref('');
|
||||||
const loadingPrompt = ref(false);
|
const loadingPrompt = ref(false);
|
||||||
const savingPrompt = ref(false);
|
const savingPrompt = ref(false);
|
||||||
|
|
||||||
const fetchMantisPrompt = async() =>
|
const fetchMantisPrompt = async() =>
|
||||||
{
|
{
|
||||||
loadingPrompt.value = true;
|
loadingPrompt.value = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await axios.get('/api/settings/mantisPrompt');
|
const response = await axios.get('/api/settings/mantisPrompt');
|
||||||
mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
|
mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error fetching Mantis prompt:', error);
|
console.error('Error fetching Mantis prompt:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
message: 'Failed to load Mantis prompt setting.',
|
message: 'Failed to load Mantis prompt setting.',
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loadingPrompt.value = false;
|
loadingPrompt.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveMantisPrompt = async() =>
|
const saveMantisPrompt = async() =>
|
||||||
{
|
{
|
||||||
savingPrompt.value = true;
|
savingPrompt.value = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
|
await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'positive',
|
color: 'positive',
|
||||||
message: 'Mantis prompt updated successfully.',
|
message: 'Mantis prompt updated successfully.',
|
||||||
icon: 'check_circle'
|
icon: 'check_circle'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error saving Mantis prompt:', error);
|
console.error('Error saving Mantis prompt:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
message: 'Failed to save Mantis prompt setting.',
|
message: 'Failed to save Mantis prompt setting.',
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
savingPrompt.value = false;
|
savingPrompt.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const emailPrompt = ref('');
|
const emailPrompt = ref('');
|
||||||
const loadingEmailPrompt = ref(false);
|
const loadingEmailPrompt = ref(false);
|
||||||
const savingEmailPrompt = ref(false);
|
const savingEmailPrompt = ref(false);
|
||||||
|
|
||||||
const fetchEmailPrompt = async() =>
|
const fetchEmailPrompt = async() =>
|
||||||
{
|
{
|
||||||
loadingEmailPrompt.value = true;
|
loadingEmailPrompt.value = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await axios.get('/api/settings/emailPrompt');
|
const response = await axios.get('/api/settings/emailPrompt');
|
||||||
emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
|
emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error fetching Email prompt:', error);
|
console.error('Error fetching Email prompt:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
message: 'Failed to load Email prompt setting.',
|
message: 'Failed to load Email prompt setting.',
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loadingEmailPrompt.value = false;
|
loadingEmailPrompt.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEmailPrompt = async() =>
|
const saveEmailPrompt = async() =>
|
||||||
{
|
{
|
||||||
savingEmailPrompt.value = true;
|
savingEmailPrompt.value = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
|
await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'positive',
|
color: 'positive',
|
||||||
message: 'Email prompt updated successfully.',
|
message: 'Email prompt updated successfully.',
|
||||||
icon: 'check_circle'
|
icon: 'check_circle'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error('Error saving Email prompt:', error);
|
console.error('Error saving Email prompt:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
color: 'negative',
|
color: 'negative',
|
||||||
message: 'Failed to save Email prompt setting.',
|
message: 'Failed to save Email prompt setting.',
|
||||||
icon: 'report_problem'
|
icon: 'report_problem'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
savingEmailPrompt.value = false;
|
savingEmailPrompt.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() =>
|
onMounted(() =>
|
||||||
{
|
{
|
||||||
fetchMantisPrompt();
|
fetchMantisPrompt();
|
||||||
fetchEmailPrompt();
|
fetchEmailPrompt();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Add any specific styles if needed */
|
/* Add any specific styles if needed */
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { useAuthStore } from 'stores/auth'; // Import the auth store
|
||||||
* with the Router instance.
|
* with the Router instance.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default defineRouter(function({ store /* { store, ssrContext } */ })
|
export default defineRouter(function({ store /* { store, ssrContext } */ })
|
||||||
{
|
{
|
||||||
const createHistory = process.env.SERVER
|
const createHistory = process.env.SERVER
|
||||||
? createMemoryHistory
|
? createMemoryHistory
|
||||||
|
@ -29,19 +29,19 @@ export default defineRouter(function({ store /* { store, ssrContext } */ })
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigation Guard using Pinia store
|
// Navigation Guard using Pinia store
|
||||||
Router.beforeEach(async(to, from, next) =>
|
Router.beforeEach(async(to, from, next) =>
|
||||||
{
|
{
|
||||||
const authStore = useAuthStore(store); // Get store instance
|
const authStore = useAuthStore(store); // Get store instance
|
||||||
|
|
||||||
// Ensure auth status is checked, especially on first load or refresh
|
// Ensure auth status is checked, especially on first load or refresh
|
||||||
// This check might be better placed in App.vue or a boot file
|
// This check might be better placed in App.vue or a boot file
|
||||||
if (!authStore.user && !authStore.loading)
|
if (!authStore.user && !authStore.loading)
|
||||||
{ // Check only if user is not loaded and not already loading
|
{ // Check only if user is not loaded and not already loading
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await authStore.checkAuthStatus();
|
await authStore.checkAuthStatus();
|
||||||
}
|
}
|
||||||
catch (e)
|
catch (e)
|
||||||
{
|
{
|
||||||
// console.error('Initial auth check failed', e);
|
// console.error('Initial auth check failed', e);
|
||||||
// Decide how to handle initial check failure (e.g., proceed, redirect to error page)
|
// Decide how to handle initial check failure (e.g., proceed, redirect to error page)
|
||||||
|
@ -53,15 +53,15 @@ export default defineRouter(function({ store /* { store, ssrContext } */ })
|
||||||
const isPublicPage = publicPages.includes(to.path);
|
const isPublicPage = publicPages.includes(to.path);
|
||||||
const isAuthenticated = authStore.isAuthenticated; // Get status from store
|
const isAuthenticated = authStore.isAuthenticated; // Get status from store
|
||||||
|
|
||||||
if (requiresAuth && !isAuthenticated)
|
if (requiresAuth && !isAuthenticated)
|
||||||
{
|
{
|
||||||
next('/login');
|
next('/login');
|
||||||
}
|
}
|
||||||
else if (isPublicPage && isAuthenticated)
|
else if (isPublicPage && isAuthenticated)
|
||||||
{
|
{
|
||||||
next('/');
|
next('/');
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,40 +9,40 @@ export const useAuthStore = defineStore('auth', {
|
||||||
error: null, // Optional: track errors
|
error: null, // Optional: track errors
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async checkAuthStatus()
|
async checkAuthStatus()
|
||||||
{
|
{
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const res = await axios.get('/api/auth/status', {
|
const res = await axios.get('/api/auth/status', {
|
||||||
withCredentials: true, // Ensure cookies are sent with the request
|
withCredentials: true, // Ensure cookies are sent with the request
|
||||||
});
|
});
|
||||||
if (res.data.status === 'authenticated')
|
if (res.data.status === 'authenticated')
|
||||||
{
|
{
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
this.user = res.data.user;
|
this.user = res.data.user;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
this.user = null;
|
this.user = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
// console.error('Failed to check authentication status:', error);
|
// console.error('Failed to check authentication status:', error);
|
||||||
this.error = 'Could not verify login status.';
|
this.error = 'Could not verify login status.';
|
||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
this.user = null;
|
this.user = null;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Action to manually set user as logged out (e.g., after logout)
|
// Action to manually set user as logged out (e.g., after logout)
|
||||||
logout()
|
logout()
|
||||||
{
|
{
|
||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
this.user = null;
|
this.user = null;
|
||||||
|
|
|
@ -1,256 +1,256 @@
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, computed, watch } from 'vue'; // Import watch
|
import { ref, computed, watch } from 'vue'; // Import watch
|
||||||
import axios from 'boot/axios';
|
import axios from 'boot/axios';
|
||||||
|
|
||||||
export const useChatStore = defineStore('chat', () =>
|
export const useChatStore = defineStore('chat', () =>
|
||||||
{
|
{
|
||||||
const isVisible = ref(false);
|
const isVisible = ref(false);
|
||||||
const currentThreadId = ref(null);
|
const currentThreadId = ref(null);
|
||||||
const messages = ref([]); // Array of { sender: 'user' | 'bot', content: string, createdAt?: Date, loading?: boolean }
|
const messages = ref([]); // Array of { sender: 'user' | 'bot', content: string, createdAt?: Date, loading?: boolean }
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
const pollingIntervalId = ref(null); // To store the interval ID
|
const pollingIntervalId = ref(null); // To store the interval ID
|
||||||
|
|
||||||
// --- Getters ---
|
// --- Getters ---
|
||||||
const chatMessages = computed(() => messages.value);
|
const chatMessages = computed(() => messages.value);
|
||||||
const isChatVisible = computed(() => isVisible.value);
|
const isChatVisible = computed(() => isVisible.value);
|
||||||
const activeThreadId = computed(() => currentThreadId.value);
|
const activeThreadId = computed(() => currentThreadId.value);
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
|
|
||||||
// New action to create a thread if it doesn't exist
|
// New action to create a thread if it doesn't exist
|
||||||
async function createThreadIfNotExists()
|
async function createThreadIfNotExists()
|
||||||
{
|
{
|
||||||
if (currentThreadId.value) return; // Already have a thread
|
if (currentThreadId.value) return; // Already have a thread
|
||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Call the endpoint without content to just create the thread
|
// Call the endpoint without content to just create the thread
|
||||||
const response = await axios.post('/api/chat/threads', {});
|
const response = await axios.post('/api/chat/threads', {});
|
||||||
currentThreadId.value = response.data.threadId;
|
currentThreadId.value = response.data.threadId;
|
||||||
messages.value = []; // Start with an empty message list for the new thread
|
messages.value = []; // Start with an empty message list for the new thread
|
||||||
console.log('Created new chat thread:', currentThreadId.value);
|
console.log('Created new chat thread:', currentThreadId.value);
|
||||||
// Start polling now that we have a thread ID
|
// Start polling now that we have a thread ID
|
||||||
startPolling();
|
startPolling();
|
||||||
}
|
}
|
||||||
catch (err)
|
catch (err)
|
||||||
{
|
{
|
||||||
console.error('Error creating chat thread:', err);
|
console.error('Error creating chat thread:', err);
|
||||||
error.value = 'Failed to start chat.';
|
error.value = 'Failed to start chat.';
|
||||||
// Don't set isVisible to false, let the user see the error
|
// Don't set isVisible to false, let the user see the error
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleChat()
|
function toggleChat()
|
||||||
{
|
{
|
||||||
isVisible.value = !isVisible.value;
|
isVisible.value = !isVisible.value;
|
||||||
|
|
||||||
if (isVisible.value)
|
if (isVisible.value)
|
||||||
{
|
{
|
||||||
if (!currentThreadId.value)
|
if (!currentThreadId.value)
|
||||||
{
|
{
|
||||||
// If opening and no thread exists, create one
|
// If opening and no thread exists, create one
|
||||||
createThreadIfNotExists();
|
createThreadIfNotExists();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// If opening and thread exists, fetch messages if empty and start polling
|
// If opening and thread exists, fetch messages if empty and start polling
|
||||||
if (messages.value.length === 0)
|
if (messages.value.length === 0)
|
||||||
{
|
{
|
||||||
fetchMessages();
|
fetchMessages();
|
||||||
}
|
}
|
||||||
startPolling();
|
startPolling();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// If closing, stop polling
|
// If closing, stop polling
|
||||||
stopPolling();
|
stopPolling();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMessages()
|
async function fetchMessages()
|
||||||
{
|
{
|
||||||
if (!currentThreadId.value)
|
if (!currentThreadId.value)
|
||||||
{
|
{
|
||||||
console.log('No active thread to fetch messages for.');
|
console.log('No active thread to fetch messages for.');
|
||||||
// Don't try to fetch if no thread ID yet. createThreadIfNotExists handles the initial state.
|
// Don't try to fetch if no thread ID yet. createThreadIfNotExists handles the initial state.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Avoid setting isLoading if polling, maybe use a different flag? For now, keep it simple.
|
// Avoid setting isLoading if polling, maybe use a different flag? For now, keep it simple.
|
||||||
// isLoading.value = true; // Might cause flickering during polling
|
// isLoading.value = true; // Might cause flickering during polling
|
||||||
error.value = null; // Clear previous errors on fetch attempt
|
error.value = null; // Clear previous errors on fetch attempt
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await axios.get(`/api/chat/threads/${currentThreadId.value}/messages`);
|
const response = await axios.get(`/api/chat/threads/${currentThreadId.value}/messages`);
|
||||||
const newMessages = response.data.map(msg => ({
|
const newMessages = response.data.map(msg => ({
|
||||||
sender: msg.sender,
|
sender: msg.sender,
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
createdAt: new Date(msg.createdAt),
|
createdAt: new Date(msg.createdAt),
|
||||||
loading: msg.content === 'Loading...'
|
loading: msg.content === 'Loading...'
|
||||||
})).sort((a, b) => a.createdAt - b.createdAt);
|
})).sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
|
||||||
// Only update if messages have actually changed to prevent unnecessary re-renders
|
// Only update if messages have actually changed to prevent unnecessary re-renders
|
||||||
if (JSON.stringify(messages.value) !== JSON.stringify(newMessages))
|
if (JSON.stringify(messages.value) !== JSON.stringify(newMessages))
|
||||||
{
|
{
|
||||||
messages.value = newMessages;
|
messages.value = newMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (err)
|
catch (err)
|
||||||
{
|
{
|
||||||
console.error('Error fetching messages:', err);
|
console.error('Error fetching messages:', err);
|
||||||
error.value = 'Failed to load messages.';
|
error.value = 'Failed to load messages.';
|
||||||
// Don't clear messages on polling error, keep the last known state
|
// Don't clear messages on polling error, keep the last known state
|
||||||
// messages.value = [];
|
// messages.value = [];
|
||||||
stopPolling(); // Stop polling if there's an error fetching
|
stopPolling(); // Stop polling if there's an error fetching
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// isLoading.value = false;
|
// isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to start polling
|
// Function to start polling
|
||||||
function startPolling()
|
function startPolling()
|
||||||
{
|
{
|
||||||
if (pollingIntervalId.value) return; // Already polling
|
if (pollingIntervalId.value) return; // Already polling
|
||||||
if (!currentThreadId.value) return; // No thread to poll for
|
if (!currentThreadId.value) return; // No thread to poll for
|
||||||
|
|
||||||
console.log('Starting chat polling for thread:', currentThreadId.value);
|
console.log('Starting chat polling for thread:', currentThreadId.value);
|
||||||
pollingIntervalId.value = setInterval(fetchMessages, 5000); // Poll every 5 seconds
|
pollingIntervalId.value = setInterval(fetchMessages, 5000); // Poll every 5 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to stop polling
|
// Function to stop polling
|
||||||
function stopPolling()
|
function stopPolling()
|
||||||
{
|
{
|
||||||
if (pollingIntervalId.value)
|
if (pollingIntervalId.value)
|
||||||
{
|
{
|
||||||
console.log('Stopping chat polling.');
|
console.log('Stopping chat polling.');
|
||||||
clearInterval(pollingIntervalId.value);
|
clearInterval(pollingIntervalId.value);
|
||||||
pollingIntervalId.value = null;
|
pollingIntervalId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function sendMessage(content)
|
async function sendMessage(content)
|
||||||
{
|
{
|
||||||
if (!content.trim()) return;
|
if (!content.trim()) return;
|
||||||
if (!currentThreadId.value)
|
if (!currentThreadId.value)
|
||||||
{
|
{
|
||||||
error.value = 'Cannot send message: No active chat thread.';
|
error.value = 'Cannot send message: No active chat thread.';
|
||||||
console.error('Attempted to send message without a thread ID.');
|
console.error('Attempted to send message without a thread ID.');
|
||||||
return; // Should not happen if UI waits for thread creation
|
return; // Should not happen if UI waits for thread creation
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMessage = {
|
const userMessage = {
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
};
|
};
|
||||||
messages.value.push(userMessage);
|
messages.value.push(userMessage);
|
||||||
|
|
||||||
const loadingMessage = { sender: 'bot', content: '...', loading: true, createdAt: new Date(Date.now() + 1) }; // Ensure unique key/time
|
const loadingMessage = { sender: 'bot', content: '...', loading: true, createdAt: new Date(Date.now() + 1) }; // Ensure unique key/time
|
||||||
messages.value.push(loadingMessage);
|
messages.value.push(loadingMessage);
|
||||||
|
|
||||||
// Stop polling temporarily while sending a message to avoid conflicts
|
// Stop polling temporarily while sending a message to avoid conflicts
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
|
||||||
isLoading.value = true; // Indicate activity
|
isLoading.value = true; // Indicate activity
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const payload = { content: userMessage.content };
|
const payload = { content: userMessage.content };
|
||||||
// Always post to the existing thread once it's created
|
// Always post to the existing thread once it's created
|
||||||
const response = await axios.post(`/api/chat/threads/${currentThreadId.value}/messages`, payload);
|
const response = await axios.post(`/api/chat/threads/${currentThreadId.value}/messages`, payload);
|
||||||
|
|
||||||
// Remove loading indicator
|
// Remove loading indicator
|
||||||
messages.value = messages.value.filter(m => !m.loading);
|
messages.value = messages.value.filter(m => !m.loading);
|
||||||
|
|
||||||
// The POST might return the new message, but we'll rely on the next fetchMessages call
|
// The POST might return the new message, but we'll rely on the next fetchMessages call
|
||||||
// triggered by startPolling to get the latest state including any potential bot response.
|
// triggered by startPolling to get the latest state including any potential bot response.
|
||||||
|
|
||||||
// Immediately fetch messages after sending to get the updated list
|
// Immediately fetch messages after sending to get the updated list
|
||||||
await fetchMessages();
|
await fetchMessages();
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (err)
|
catch (err)
|
||||||
{
|
{
|
||||||
console.error('Error sending message:', err);
|
console.error('Error sending message:', err);
|
||||||
error.value = 'Failed to send message.';
|
error.value = 'Failed to send message.';
|
||||||
// Remove loading indicator on error
|
// Remove loading indicator on error
|
||||||
messages.value = messages.value.filter(m => !m.loading);
|
messages.value = messages.value.filter(m => !m.loading);
|
||||||
// Optionally add an error message to the chat
|
// Optionally add an error message to the chat
|
||||||
// Ensure the object is correctly formatted
|
// Ensure the object is correctly formatted
|
||||||
messages.value.push({ sender: 'bot', content: "Sorry, I couldn't send that message.", createdAt: new Date() });
|
messages.value.push({ sender: 'bot', content: "Sorry, I couldn't send that message.", createdAt: new Date() });
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
// Restart polling after sending attempt is complete
|
// Restart polling after sending attempt is complete
|
||||||
startPolling();
|
startPolling();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call this when the user logs out or the app closes if you want to clear state
|
// Call this when the user logs out or the app closes if you want to clear state
|
||||||
function resetChat()
|
function resetChat()
|
||||||
{
|
{
|
||||||
stopPolling(); // Ensure polling stops on reset
|
stopPolling(); // Ensure polling stops on reset
|
||||||
isVisible.value = false;
|
isVisible.value = false;
|
||||||
currentThreadId.value = null;
|
currentThreadId.value = null;
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for visibility changes to manage polling (alternative to putting logic in toggleChat)
|
// Watch for visibility changes to manage polling (alternative to putting logic in toggleChat)
|
||||||
// watch(isVisible, (newValue) => {
|
// watch(isVisible, (newValue) => {
|
||||||
// if (newValue && currentThreadId.value) {
|
// if (newValue && currentThreadId.value) {
|
||||||
// startPolling();
|
// startPolling();
|
||||||
// } else {
|
// } else {
|
||||||
// stopPolling();
|
// stopPolling();
|
||||||
// }
|
// }
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// Watch for thread ID changes (e.g., after creation)
|
// Watch for thread ID changes (e.g., after creation)
|
||||||
// watch(currentThreadId, (newId) => {
|
// watch(currentThreadId, (newId) => {
|
||||||
// if (newId && isVisible.value) {
|
// if (newId && isVisible.value) {
|
||||||
// messages.value = []; // Clear old messages if any
|
// messages.value = []; // Clear old messages if any
|
||||||
// fetchMessages(); // Fetch messages for the new thread
|
// fetchMessages(); // Fetch messages for the new thread
|
||||||
// startPolling(); // Start polling for the new thread
|
// startPolling(); // Start polling for the new thread
|
||||||
// } else {
|
// } else {
|
||||||
// stopPolling(); // Stop polling if thread ID becomes null
|
// stopPolling(); // Stop polling if thread ID becomes null
|
||||||
// }
|
// }
|
||||||
// });
|
// });
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State refs
|
// State refs
|
||||||
isVisible,
|
isVisible,
|
||||||
currentThreadId,
|
currentThreadId,
|
||||||
messages,
|
messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
|
||||||
// Computed getters
|
// Computed getters
|
||||||
chatMessages,
|
chatMessages,
|
||||||
isChatVisible,
|
isChatVisible,
|
||||||
activeThreadId,
|
activeThreadId,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
toggleChat,
|
toggleChat,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
fetchMessages, // Expose if needed externally
|
fetchMessages, // Expose if needed externally
|
||||||
resetChat,
|
resetChat,
|
||||||
// Expose polling control if needed externally, though typically managed internally
|
// Expose polling control if needed externally, though typically managed internally
|
||||||
// startPolling,
|
// startPolling,
|
||||||
// stopPolling,
|
// stopPolling,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,21 +1,21 @@
|
||||||
import { defineStore } from '#q-app/wrappers';
|
import { defineStore } from '#q-app/wrappers';
|
||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* If not building with SSR mode, you can
|
* If not building with SSR mode, you can
|
||||||
* directly export the Store instantiation;
|
* directly export the Store instantiation;
|
||||||
*
|
*
|
||||||
* The function below can be async too; either use
|
* The function below can be async too; either use
|
||||||
* async/await or return a Promise which resolves
|
* async/await or return a Promise which resolves
|
||||||
* with the Store instance.
|
* with the Store instance.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default defineStore((/* { ssrContext } */) =>
|
export default defineStore((/* { ssrContext } */) =>
|
||||||
{
|
{
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
|
|
||||||
// You can add Pinia plugins here
|
// You can add Pinia plugins here
|
||||||
// pinia.use(SomePiniaPlugin)
|
// pinia.use(SomePiniaPlugin)
|
||||||
|
|
||||||
return pinia;
|
return pinia;
|
||||||
});
|
});
|
Loading…
Add table
Add a link
Reference in a new issue