This commit is contained in:
Cameron Redmore 2025-04-25 14:06:02 +01:00
commit 61d274391b
31 changed files with 4682 additions and 4612 deletions

View file

@ -13,5 +13,8 @@
"[vue]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"editor.formatOnSave": true
"editor.formatOnSave": true,
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"editor.trimAutoWhitespace": true
}

View file

@ -1,91 +1,97 @@
import stylistic from '@stylistic/eslint-plugin';
import globals from 'globals';
import pluginVue from 'eslint-plugin-vue';
import pluginQuasar from '@quasar/app-vite/eslint';
export default
[
{
/**
* Ignore the following files.
* Please note that pluginQuasar.configs.recommended() already ignores
* the "node_modules" folder for you (and all other Quasar project
* relevant folders and files).
*
* ESLint requires "ignores" key to be the only one in this object
*/
// ignores: []
},
...pluginQuasar.configs.recommended(),
/**
* https://eslint.vuejs.org
*
* pluginVue.configs.base
* -> Settings and rules to enable correct ESLint parsing.
* pluginVue.configs[ 'flat/essential']
* -> base, plus rules to prevent errors or unintended behavior.
* pluginVue.configs["flat/strongly-recommended"]
* -> Above, plus rules to considerably improve code readability and/or dev experience.
* pluginVue.configs["flat/recommended"]
* -> Above, plus rules to enforce subjective community defaults to ensure consistency.
*/
...pluginVue.configs['flat/essential'],
...pluginVue.configs['flat/strongly-recommended'],
{
plugins: {
'@stylistic': stylistic,
},
languageOptions:
{
ecmaVersion: 'latest',
sourceType: 'module',
globals:
{
...globals.browser,
...globals.node, // SSR, Electron, config files
process: 'readonly', // process.env.*
ga: 'readonly', // Google Analytics
cordova: 'readonly',
Capacitor: 'readonly',
chrome: 'readonly', // BEX related
browser: 'readonly' // BEX related
}
},
// add your custom rules here
rules:
{
'prefer-promise-reject-errors': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
// enforce Allman brace style
'@stylistic/brace-style': ['warn', 'allman'],
'@stylistic/indent': ['warn', 2],
//Enforce single quotes
'@stylistic/quotes': ['warn', 'single', { avoidEscape: true }],
'@stylistic/quote-props': ['warn', 'as-needed', { keywords: true, unnecessary: true, numbers: true }],
//Enforce semicolon
'@stylistic/semi': ['warn', 'always'],
'@stylistic/space-before-function-paren': ['warn', 'never'],
}
},
{
files: ['src-pwa/custom-service-worker.js'],
languageOptions:
{
globals:
{
...globals.serviceworker
}
}
}
import stylistic from '@stylistic/eslint-plugin';
import globals from 'globals';
import pluginVue from 'eslint-plugin-vue';
import pluginQuasar from '@quasar/app-vite/eslint';
export default
[
{
/**
* Ignore the following files.
* Please note that pluginQuasar.configs.recommended() already ignores
* the "node_modules" folder for you (and all other Quasar project
* relevant folders and files).
*
* ESLint requires "ignores" key to be the only one in this object
*/
// ignores: []
},
...pluginQuasar.configs.recommended(),
/**
* https://eslint.vuejs.org
*
* pluginVue.configs.base
* -> Settings and rules to enable correct ESLint parsing.
* pluginVue.configs[ 'flat/essential']
* -> base, plus rules to prevent errors or unintended behavior.
* pluginVue.configs["flat/strongly-recommended"]
* -> Above, plus rules to considerably improve code readability and/or dev experience.
* pluginVue.configs["flat/recommended"]
* -> Above, plus rules to enforce subjective community defaults to ensure consistency.
*/
...pluginVue.configs['flat/essential'],
...pluginVue.configs['flat/strongly-recommended'],
{
plugins: {
'@stylistic': stylistic,
},
languageOptions:
{
ecmaVersion: 'latest',
sourceType: 'module',
globals:
{
...globals.browser,
...globals.node, // SSR, Electron, config files
process: 'readonly', // process.env.*
ga: 'readonly', // Google Analytics
cordova: 'readonly',
Capacitor: 'readonly',
chrome: 'readonly', // BEX related
browser: 'readonly' // BEX related
}
},
// add your custom rules here
rules:
{
'prefer-promise-reject-errors': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
// enforce Allman brace style
'@stylistic/brace-style': ['warn', 'allman'],
'@stylistic/indent': ['warn', 2],
//Enforce single quotes
'@stylistic/quotes': ['warn', 'single', { avoidEscape: true }],
'@stylistic/quote-props': ['warn', 'as-needed', { keywords: true, unnecessary: true, numbers: true }],
//Enforce semicolon
'@stylistic/semi': ['warn', 'always'],
'@stylistic/space-before-function-paren': ['warn', 'never'],
//Force LF and not CRLF
'@stylistic/linebreak-style': ['warn', 'unix'],
//Force no trailing spaces
'@stylistic/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
}
},
{
files: ['src-pwa/custom-service-worker.js'],
languageOptions:
{
globals:
{
...globals.serviceworker
}
}
}
];

View file

@ -3,7 +3,7 @@
import { defineConfig } from '#q-app/wrappers';
export default defineConfig((/* ctx */) =>
export default defineConfig((/* ctx */) =>
{
return {
// 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
extras: [
// 'ionicons-v4',
// 'mdi-v7',
'mdi-v7',
// 'fontawesome-v6',
// 'eva-icons',
// 'themify',
@ -59,7 +59,7 @@ export default defineConfig((/* ctx */) =>
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
// vitePlugins: [
// [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ]
// ]
@ -77,7 +77,7 @@ export default defineConfig((/* ctx */) =>
devServer: {
// https: true,
open: true, // opens browser window automatically
//Add a proxy from /api to the backend server for dev usage
proxy: {
'/api': {

View file

@ -1,7 +1,7 @@
import { PrismaClient } from '@prisma/client';
// Instantiate Prisma Client
const prisma = new PrismaClient();
// Export the Prisma Client instance for use in other modules
import { PrismaClient } from '@prisma/client';
// Instantiate Prisma Client
const prisma = new PrismaClient();
// Export the Prisma Client instance for use in other modules
export default prisma;

View file

@ -1,12 +1,12 @@
// src-ssr/middlewares/authMiddleware.js
export function requireAuth(req, res, next)
{
if (!req.session || !req.session.loggedInUserId)
{
// User is not authenticated
return res.status(401).json({ error: 'Authentication required' });
}
// User is authenticated, proceed to the next middleware or route handler
next();
}
// src-ssr/middlewares/authMiddleware.js
export function requireAuth(req, res, next)
{
if (!req.session || !req.session.loggedInUserId)
{
// User is not authenticated
return res.status(401).json({ error: 'Authentication required' });
}
// User is authenticated, proceed to the next middleware or route handler
next();
}

File diff suppressed because it is too large Load diff

View file

@ -1,459 +1,459 @@
// src-ssr/routes/auth.js
import express from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already
import prisma from '../database.js';
import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store
const router = express.Router();
// Helper function to get user authenticators
async function getUserAuthenticators(userId)
{
return prisma.authenticator.findMany({
where: { userId },
select: {
credentialID: true,
credentialPublicKey: true,
counter: true,
transports: true,
},
});
}
// Helper function to get a user by username
async function getUserByUsername(username)
{
return prisma.user.findUnique({ where: { username } });
}
// Helper function to get a user by ID
async function getUserById(id)
{
return prisma.user.findUnique({ where: { id } });
}
// Helper function to get an authenticator by credential ID
async function getAuthenticatorByCredentialID(credentialID)
{
return prisma.authenticator.findUnique({ where: { credentialID } });
}
// Generate Registration Options
router.post('/generate-registration-options', async(req, res) =>
{
const { username } = req.body;
if (!username)
{
return res.status(400).json({ error: 'Username is required' });
}
try
{
let user = await getUserByUsername(username);
// If user doesn't exist, create one
if (!user)
{
user = await prisma.user.create({
data: { username },
});
}
const userAuthenticators = await getUserAuthenticators(user.id);
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
if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id)
{
return res.status(403).json({ error: 'Invalid registration attempt.' });
}
}
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.username,
// Don't prompt users for additional authenticators if they've already registered some
excludeCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID, // Use isoBase64URL helper
type: 'public-key',
// Optional: Specify transports if you know them
transports: auth.transports ? auth.transports.split(',') : undefined,
})),
authenticatorSelection: {
// Defaults
residentKey: 'required',
userVerification: 'preferred',
},
// Strong advice: Always require attestation for registration
attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production
});
// Store the challenge
challengeStore.set(user.id, options.challenge);
req.session.userId = user.id; // Temporarily store userId in session for verification step
res.json(options);
}
catch (error)
{
console.error('Registration options error:', error);
res.status(500).json({ error: 'Failed to generate registration options' });
}
});
// Verify Registration
router.post('/verify-registration', async(req, res) =>
{
const { registrationResponse } = req.body;
const userId = req.session.userId; // Retrieve userId stored during options generation
if (!userId)
{
return res.status(400).json({ error: 'User session not found. Please start registration again.' });
}
const expectedChallenge = challengeStore.get(userId);
if (!expectedChallenge)
{
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try
{
const user = await getUserById(userId);
if (!user)
{
return res.status(404).json({ error: 'User not found' });
}
const verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false, // Adjust based on your requirements
});
const { verified, registrationInfo } = verification;
console.log(verification);
if (verified && registrationInfo)
{
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
const credentialID = credential.id;
const credentialPublicKey = credential.publicKey;
const counter = credential.counter;
const transports = credential.transports || []; // Use empty array if transports are not provided
// Check if authenticator with this ID already exists
const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID));
if (existingAuthenticator)
{
return res.status(409).json({ error: 'Authenticator already registered' });
}
// Save the authenticator
await prisma.authenticator.create({
data: {
credentialID, // Store as Base64URL string
credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes
counter: BigInt(counter), // Store as BigInt
credentialDeviceType,
credentialBackedUp,
transports: transports.join(','), // Store transports as comma-separated string
userId: user.id,
},
});
// Clear the challenge and temporary userId
challengeStore.delete(userId);
delete req.session.userId;
// Log the user in by setting the final session userId
req.session.loggedInUserId = user.id;
res.json({ verified: true });
}
else
{
res.status(400).json({ error: 'Registration verification failed' });
}
}
catch (error)
{
console.error('Registration verification error:', error);
challengeStore.delete(userId); // Clean up challenge on error
delete req.session.userId;
res.status(500).json({ error: 'Failed to verify registration', details: error.message });
}
});
// Generate Authentication Options
router.post('/generate-authentication-options', async(req, res) =>
{
const { username } = req.body;
try
{
let user;
if (username)
{
user = await getUserByUsername(username);
}
else if (req.session.loggedInUserId)
{
// If already logged in, allow re-authentication (e.g., for step-up)
user = await getUserById(req.session.loggedInUserId);
}
if (!user)
{
return res.status(404).json({ error: 'User not found' });
}
console.log('User found:', user);
const userAuthenticators = await getUserAuthenticators(user.id);
console.log('User authenticators:', userAuthenticators);
const options = await generateAuthenticationOptions({
rpID,
// Require users to use a previously-registered authenticator
allowCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID,
type: 'public-key',
transports: auth.transports ? auth.transports.split(',') : undefined,
})),
userVerification: 'preferred',
});
// Store the challenge associated with the user ID for verification
challengeStore.set(user.id, options.challenge);
req.session.challengeUserId = user.id; // Store user ID associated with this challenge
res.json(options);
}
catch (error)
{
console.error('Authentication options error:', error);
res.status(500).json({ error: 'Failed to generate authentication options' });
}
});
// Verify Authentication
router.post('/verify-authentication', async(req, res) =>
{
const { authenticationResponse } = req.body;
const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge
if (!challengeUserId)
{
return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' });
}
const expectedChallenge = challengeStore.get(challengeUserId);
if (!expectedChallenge)
{
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try
{
const user = await getUserById(challengeUserId);
if (!user)
{
return res.status(404).json({ error: 'User associated with challenge not found' });
}
const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id);
if (!authenticator)
{
return res.status(404).json({ error: 'Authenticator not found' });
}
// Ensure the authenticator belongs to the user attempting to log in
if (authenticator.userId !== user.id)
{
return res.status(403).json({ error: 'Authenticator does not belong to this user' });
}
const verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: authenticator.credentialID,
publicKey: authenticator.credentialPublicKey,
counter: authenticator.counter.toString(), // Convert BigInt to string for comparison
transports: authenticator.transports ? authenticator.transports.split(',') : undefined,
},
requireUserVerification: false, // Enforce user verification
});
const { verified, authenticationInfo } = verification;
if (verified)
{
// Update the authenticator counter
await prisma.authenticator.update({
where: { credentialID: authenticator.credentialID },
data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter
});
// Clear the challenge and associated user ID
challengeStore.delete(challengeUserId);
delete req.session.challengeUserId;
// Log the user in
req.session.loggedInUserId = user.id;
res.json({ verified: true, user: { id: user.id, username: user.username } });
}
else
{
res.status(400).json({ error: 'Authentication verification failed' });
}
}
catch (error)
{
console.error('Authentication verification error:', error);
challengeStore.delete(challengeUserId); // Clean up challenge on error
delete req.session.challengeUserId;
res.status(500).json({ error: 'Failed to verify authentication', details: error.message });
}
});
// GET Passkeys for Logged-in User
router.get('/passkeys', async(req, res) =>
{
if (!req.session.loggedInUserId)
{
return res.status(401).json({ error: 'Not authenticated' });
}
try
{
const userId = req.session.loggedInUserId;
const authenticators = await prisma.authenticator.findMany({
where: { userId },
select: {
credentialID: true, // Already Base64URL string
// Add other fields if needed, e.g., createdAt if you add it to the schema
// createdAt: true,
},
});
// No need to convert credentialID here as it's stored as Base64URL string
res.json(authenticators);
}
catch (error)
{
console.error('Error fetching passkeys:', error);
res.status(500).json({ error: 'Failed to fetch passkeys' });
}
});
// DELETE Passkey
router.delete('/passkeys/:credentialID', async(req, res) =>
{
if (!req.session.loggedInUserId)
{
return res.status(401).json({ error: 'Not authenticated' });
}
const { credentialID } = req.params; // This is already a Base64URL string from the client
if (!credentialID)
{
return res.status(400).json({ error: 'Credential ID is required' });
}
try
{
const userId = req.session.loggedInUserId;
// Find the authenticator first to ensure it belongs to the logged-in user
const authenticator = await prisma.authenticator.findUnique({
where: { credentialID: credentialID }, // Use the Base64URL string directly
});
if (!authenticator)
{
return res.status(404).json({ error: 'Passkey not found' });
}
// Security check: Ensure the passkey belongs to the user trying to delete it
if (authenticator.userId !== userId)
{
return res.status(403).json({ error: 'Permission denied' });
}
// Delete the authenticator
await prisma.authenticator.delete({
where: { credentialID: credentialID },
});
res.json({ message: 'Passkey deleted successfully' });
}
catch (error)
{
console.error('Error deleting passkey:', error);
// Handle potential Prisma errors, e.g., record not found if deleted between check and delete
if (error.code === 'P2025')
{ // Prisma code for record not found on delete/update
return res.status(404).json({ error: 'Passkey not found' });
}
res.status(500).json({ error: 'Failed to delete passkey' });
}
});
// Check Authentication Status
router.get('/status', async(req, res) =>
{
if (req.session.loggedInUserId)
{
const user = await getUserById(req.session.loggedInUserId);
if (!user)
{
req.session.destroy(err =>
{});
return res.status(401).json({ status: 'unauthenticated' });
}
return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email } });
}
res.json({ status: 'unauthenticated' });
});
// Logout
router.post('/logout', (req, res) =>
{
req.session.destroy(err =>
{
if (err)
{
console.error('Logout error:', err);
return res.status(500).json({ error: 'Failed to logout' });
}
res.json({ message: 'Logged out successfully' });
});
});
// src-ssr/routes/auth.js
import express from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already
import prisma from '../database.js';
import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store
const router = express.Router();
// Helper function to get user authenticators
async function getUserAuthenticators(userId)
{
return prisma.authenticator.findMany({
where: { userId },
select: {
credentialID: true,
credentialPublicKey: true,
counter: true,
transports: true,
},
});
}
// Helper function to get a user by username
async function getUserByUsername(username)
{
return prisma.user.findUnique({ where: { username } });
}
// Helper function to get a user by ID
async function getUserById(id)
{
return prisma.user.findUnique({ where: { id } });
}
// Helper function to get an authenticator by credential ID
async function getAuthenticatorByCredentialID(credentialID)
{
return prisma.authenticator.findUnique({ where: { credentialID } });
}
// Generate Registration Options
router.post('/generate-registration-options', async(req, res) =>
{
const { username } = req.body;
if (!username)
{
return res.status(400).json({ error: 'Username is required' });
}
try
{
let user = await getUserByUsername(username);
// If user doesn't exist, create one
if (!user)
{
user = await prisma.user.create({
data: { username },
});
}
const userAuthenticators = await getUserAuthenticators(user.id);
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
if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id)
{
return res.status(403).json({ error: 'Invalid registration attempt.' });
}
}
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.username,
// Don't prompt users for additional authenticators if they've already registered some
excludeCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID, // Use isoBase64URL helper
type: 'public-key',
// Optional: Specify transports if you know them
transports: auth.transports ? auth.transports.split(',') : undefined,
})),
authenticatorSelection: {
// Defaults
residentKey: 'required',
userVerification: 'preferred',
},
// Strong advice: Always require attestation for registration
attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production
});
// Store the challenge
challengeStore.set(user.id, options.challenge);
req.session.userId = user.id; // Temporarily store userId in session for verification step
res.json(options);
}
catch (error)
{
console.error('Registration options error:', error);
res.status(500).json({ error: 'Failed to generate registration options' });
}
});
// Verify Registration
router.post('/verify-registration', async(req, res) =>
{
const { registrationResponse } = req.body;
const userId = req.session.userId; // Retrieve userId stored during options generation
if (!userId)
{
return res.status(400).json({ error: 'User session not found. Please start registration again.' });
}
const expectedChallenge = challengeStore.get(userId);
if (!expectedChallenge)
{
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try
{
const user = await getUserById(userId);
if (!user)
{
return res.status(404).json({ error: 'User not found' });
}
const verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false, // Adjust based on your requirements
});
const { verified, registrationInfo } = verification;
console.log(verification);
if (verified && registrationInfo)
{
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
const credentialID = credential.id;
const credentialPublicKey = credential.publicKey;
const counter = credential.counter;
const transports = credential.transports || []; // Use empty array if transports are not provided
// Check if authenticator with this ID already exists
const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID));
if (existingAuthenticator)
{
return res.status(409).json({ error: 'Authenticator already registered' });
}
// Save the authenticator
await prisma.authenticator.create({
data: {
credentialID, // Store as Base64URL string
credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes
counter: BigInt(counter), // Store as BigInt
credentialDeviceType,
credentialBackedUp,
transports: transports.join(','), // Store transports as comma-separated string
userId: user.id,
},
});
// Clear the challenge and temporary userId
challengeStore.delete(userId);
delete req.session.userId;
// Log the user in by setting the final session userId
req.session.loggedInUserId = user.id;
res.json({ verified: true });
}
else
{
res.status(400).json({ error: 'Registration verification failed' });
}
}
catch (error)
{
console.error('Registration verification error:', error);
challengeStore.delete(userId); // Clean up challenge on error
delete req.session.userId;
res.status(500).json({ error: 'Failed to verify registration', details: error.message });
}
});
// Generate Authentication Options
router.post('/generate-authentication-options', async(req, res) =>
{
const { username } = req.body;
try
{
let user;
if (username)
{
user = await getUserByUsername(username);
}
else if (req.session.loggedInUserId)
{
// If already logged in, allow re-authentication (e.g., for step-up)
user = await getUserById(req.session.loggedInUserId);
}
if (!user)
{
return res.status(404).json({ error: 'User not found' });
}
console.log('User found:', user);
const userAuthenticators = await getUserAuthenticators(user.id);
console.log('User authenticators:', userAuthenticators);
const options = await generateAuthenticationOptions({
rpID,
// Require users to use a previously-registered authenticator
allowCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID,
type: 'public-key',
transports: auth.transports ? auth.transports.split(',') : undefined,
})),
userVerification: 'preferred',
});
// Store the challenge associated with the user ID for verification
challengeStore.set(user.id, options.challenge);
req.session.challengeUserId = user.id; // Store user ID associated with this challenge
res.json(options);
}
catch (error)
{
console.error('Authentication options error:', error);
res.status(500).json({ error: 'Failed to generate authentication options' });
}
});
// Verify Authentication
router.post('/verify-authentication', async(req, res) =>
{
const { authenticationResponse } = req.body;
const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge
if (!challengeUserId)
{
return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' });
}
const expectedChallenge = challengeStore.get(challengeUserId);
if (!expectedChallenge)
{
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try
{
const user = await getUserById(challengeUserId);
if (!user)
{
return res.status(404).json({ error: 'User associated with challenge not found' });
}
const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id);
if (!authenticator)
{
return res.status(404).json({ error: 'Authenticator not found' });
}
// Ensure the authenticator belongs to the user attempting to log in
if (authenticator.userId !== user.id)
{
return res.status(403).json({ error: 'Authenticator does not belong to this user' });
}
const verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: authenticator.credentialID,
publicKey: authenticator.credentialPublicKey,
counter: authenticator.counter.toString(), // Convert BigInt to string for comparison
transports: authenticator.transports ? authenticator.transports.split(',') : undefined,
},
requireUserVerification: false, // Enforce user verification
});
const { verified, authenticationInfo } = verification;
if (verified)
{
// Update the authenticator counter
await prisma.authenticator.update({
where: { credentialID: authenticator.credentialID },
data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter
});
// Clear the challenge and associated user ID
challengeStore.delete(challengeUserId);
delete req.session.challengeUserId;
// Log the user in
req.session.loggedInUserId = user.id;
res.json({ verified: true, user: { id: user.id, username: user.username } });
}
else
{
res.status(400).json({ error: 'Authentication verification failed' });
}
}
catch (error)
{
console.error('Authentication verification error:', error);
challengeStore.delete(challengeUserId); // Clean up challenge on error
delete req.session.challengeUserId;
res.status(500).json({ error: 'Failed to verify authentication', details: error.message });
}
});
// GET Passkeys for Logged-in User
router.get('/passkeys', async(req, res) =>
{
if (!req.session.loggedInUserId)
{
return res.status(401).json({ error: 'Not authenticated' });
}
try
{
const userId = req.session.loggedInUserId;
const authenticators = await prisma.authenticator.findMany({
where: { userId },
select: {
credentialID: true, // Already Base64URL string
// Add other fields if needed, e.g., createdAt if you add it to the schema
// createdAt: true,
},
});
// No need to convert credentialID here as it's stored as Base64URL string
res.json(authenticators);
}
catch (error)
{
console.error('Error fetching passkeys:', error);
res.status(500).json({ error: 'Failed to fetch passkeys' });
}
});
// DELETE Passkey
router.delete('/passkeys/:credentialID', async(req, res) =>
{
if (!req.session.loggedInUserId)
{
return res.status(401).json({ error: 'Not authenticated' });
}
const { credentialID } = req.params; // This is already a Base64URL string from the client
if (!credentialID)
{
return res.status(400).json({ error: 'Credential ID is required' });
}
try
{
const userId = req.session.loggedInUserId;
// Find the authenticator first to ensure it belongs to the logged-in user
const authenticator = await prisma.authenticator.findUnique({
where: { credentialID: credentialID }, // Use the Base64URL string directly
});
if (!authenticator)
{
return res.status(404).json({ error: 'Passkey not found' });
}
// Security check: Ensure the passkey belongs to the user trying to delete it
if (authenticator.userId !== userId)
{
return res.status(403).json({ error: 'Permission denied' });
}
// Delete the authenticator
await prisma.authenticator.delete({
where: { credentialID: credentialID },
});
res.json({ message: 'Passkey deleted successfully' });
}
catch (error)
{
console.error('Error deleting passkey:', error);
// Handle potential Prisma errors, e.g., record not found if deleted between check and delete
if (error.code === 'P2025')
{ // Prisma code for record not found on delete/update
return res.status(404).json({ error: 'Passkey not found' });
}
res.status(500).json({ error: 'Failed to delete passkey' });
}
});
// Check Authentication Status
router.get('/status', async(req, res) =>
{
if (req.session.loggedInUserId)
{
const user = await getUserById(req.session.loggedInUserId);
if (!user)
{
req.session.destroy(err =>
{});
return res.status(401).json({ status: 'unauthenticated' });
}
return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email } });
}
res.json({ status: 'unauthenticated' });
});
// Logout
router.post('/logout', (req, res) =>
{
req.session.destroy(err =>
{
if (err)
{
console.error('Logout error:', err);
return res.status(500).json({ error: 'Failed to logout' });
}
res.json({ message: 'Logged out successfully' });
});
});
export default router;

View file

@ -1,164 +1,164 @@
import { Router } from 'express';
import prisma from '../database.js';
import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware
import { askGeminiChat } from '../utils/gemini.js';
const router = Router();
// Apply the authentication middleware to all chat routes
router.use(requireAuth);
// POST /api/chat/threads - Create a new chat thread (optionally with a first message)
router.post('/threads', async(req, res) =>
{
const { content } = req.body; // Content is now optional
// If content is provided, validate it
if (content && (typeof content !== 'string' || content.trim().length === 0))
{
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() })) : []
});
import { Router } from 'express';
import prisma from '../database.js';
import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware
import { askGeminiChat } from '../utils/gemini.js';
const router = Router();
// Apply the authentication middleware to all chat routes
router.use(requireAuth);
// POST /api/chat/threads - Create a new chat thread (optionally with a first message)
router.post('/threads', async(req, res) =>
{
const { content } = req.body; // Content is now optional
// If content is provided, validate it
if (content && (typeof content !== 'string' || content.trim().length === 0))
{
return res.status(400).json({ error: 'Message content cannot be empty if provided.' });
}
catch (error)
{
console.error('Error creating chat thread:', error);
res.status(500).json({ error: 'Failed to create chat thread.' });
}
});
// GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
router.get('/threads/:threadId/messages', async(req, res) =>
{
const { threadId } = req.params;
try
{
const messages = await prisma.chatMessage.findMany({
where: {
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() })));
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)
{
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 creating chat thread:', error);
res.status(500).json({ error: 'Failed to create chat thread.' });
}
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;
});
// GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
router.get('/threads/:threadId/messages', async(req, res) =>
{
const { threadId } = req.params;
try
{
const messages = await prisma.chatMessage.findMany({
where: {
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;

View file

@ -65,15 +65,15 @@ app.use(session({
// Schedule the Mantis summary task
// 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...');
try
try
{
await generateAndStoreMantisSummary();
console.log('Scheduled Mantis summary task completed.');
}
catch (error)
catch (error)
{
console.error('Error running scheduled Mantis summary task:', error);
}
@ -96,14 +96,14 @@ app.use('/api/chat', chatRoutes);
// place here any middlewares that
// absolutely need to run before anything else
if (process.env.PROD)
if (process.env.PROD)
{
app.use(compression());
}
app.use(express.static('public', { index: false }));
app.listen(8000, () =>
app.listen(8000, () =>
{
console.log('Server is running on http://localhost:8000');
});

View file

@ -1,169 +1,169 @@
import axios from 'axios';
import prisma from '../database.js'; // Import Prisma client
import { getSetting } from '../utils/settings.js';
import { askGemini } from '../utils/gemini.js';
const usernameMap = {
credmore: 'Cameron Redmore',
dgibson: 'Dane Gibson',
egzibovskis: 'Ed Gzibovskis',
ascotney: 'Amanda Scotney',
gclough: 'Garry Clough',
slee: 'Sarah Lee',
dwalker: 'Dave Walker',
askaith: 'Amy Skaith',
dpotter: 'Danny Potter',
msmart: 'Michael Smart',
// Add other usernames as needed
};
async function getMantisTickets()
{
const MANTIS_API_KEY = await getSetting('MANTIS_API_KEY');
const MANTIS_API_ENDPOINT = await getSetting('MANTIS_API_ENDPOINT');
if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY)
{
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 headers = {
Authorization: `${MANTIS_API_KEY}`,
Accept: 'application/json',
'Content-Type': 'application/json',
};
try
{
const response = await axios.get(url, { headers });
const tickets = response.data.issues.filter((ticket) =>
{
const ticketDate = new Date(ticket.updated_at);
const thresholdDate = new Date();
const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ...
// Go back 4 days if Monday (to include Fri, Sat, Sun), otherwise 2 days
const daysToSubtract = currentDay === 1 ? 4 : 2;
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
return ticketDate >= thresholdDate;
}).map((ticket) =>
{
return {
id: ticket.id,
summary: ticket.summary,
description: ticket.description,
created_at: ticket.created_at,
updated_at: ticket.updated_at,
reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access
notes: (ticket.notes ? ticket.notes.filter((note) =>
{
const noteDate = new Date(note.created_at);
const thresholdDate = new Date();
const currentDay = thresholdDate.getDay();
const daysToSubtract = currentDay === 1 ? 4 : 2;
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
return noteDate >= thresholdDate;
}) : []).map((note) =>
{
const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
return {
reporter,
created_at: note.created_at,
text: note.text,
};
}),
};
});
return tickets;
}
catch (error)
{
console.error('Error fetching Mantis tickets:', error.message);
// Check if it's an Axios error and provide more details
if (axios.isAxiosError(error))
{
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.message}`);
}
}
export async function generateAndStoreMantisSummary()
{
try
{
// Get the prompt from the database settings using Prisma
const setting = await prisma.setting.findUnique({
where: { key: 'mantisPrompt' },
select: { value: true }
});
const promptTemplate = setting?.value;
if (!promptTemplate)
{
console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
return;
}
const tickets = await getMantisTickets();
let summaryText;
if (tickets.length === 0)
{
summaryText = 'No Mantis tickets updated recently.';
console.log('No recent Mantis tickets found.');
}
else
{
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
summaryText = await askGemini(prompt);
console.log('Mantis summary generated successfully by AI.');
}
// Store the summary in the database using Prisma upsert
const today = new Date();
today.setUTCHours(0, 0, 0, 0); // Use UTC start of day for consistency
await prisma.mantisSummary.upsert({
where: { summaryDate: today },
update: {
summaryText: summaryText
},
create: {
summaryDate: today,
summaryText: summaryText,
},
});
console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
}
catch (error)
{
console.error('Error during Mantis summary generation/storage:', error);
}
}
export async function generateTodaysSummary()
{
console.log('Triggering Mantis summary generation via generateTodaysSummary...');
try
{
await generateAndStoreMantisSummary();
return { success: true, message: 'Summary generation process initiated.' };
}
catch (error)
{
console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
throw new Error('Failed to initiate Mantis summary generation.');
}
}
import axios from 'axios';
import prisma from '../database.js'; // Import Prisma client
import { getSetting } from '../utils/settings.js';
import { askGemini } from '../utils/gemini.js';
const usernameMap = {
credmore: 'Cameron Redmore',
dgibson: 'Dane Gibson',
egzibovskis: 'Ed Gzibovskis',
ascotney: 'Amanda Scotney',
gclough: 'Garry Clough',
slee: 'Sarah Lee',
dwalker: 'Dave Walker',
askaith: 'Amy Skaith',
dpotter: 'Danny Potter',
msmart: 'Michael Smart',
// Add other usernames as needed
};
async function getMantisTickets()
{
const MANTIS_API_KEY = await getSetting('MANTIS_API_KEY');
const MANTIS_API_ENDPOINT = await getSetting('MANTIS_API_ENDPOINT');
if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY)
{
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 headers = {
Authorization: `${MANTIS_API_KEY}`,
Accept: 'application/json',
'Content-Type': 'application/json',
};
try
{
const response = await axios.get(url, { headers });
const tickets = response.data.issues.filter((ticket) =>
{
const ticketDate = new Date(ticket.updated_at);
const thresholdDate = new Date();
const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ...
// Go back 4 days if Monday (to include Fri, Sat, Sun), otherwise 2 days
const daysToSubtract = currentDay === 1 ? 4 : 2;
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
return ticketDate >= thresholdDate;
}).map((ticket) =>
{
return {
id: ticket.id,
summary: ticket.summary,
description: ticket.description,
created_at: ticket.created_at,
updated_at: ticket.updated_at,
reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access
notes: (ticket.notes ? ticket.notes.filter((note) =>
{
const noteDate = new Date(note.created_at);
const thresholdDate = new Date();
const currentDay = thresholdDate.getDay();
const daysToSubtract = currentDay === 1 ? 4 : 2;
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
return noteDate >= thresholdDate;
}) : []).map((note) =>
{
const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
return {
reporter,
created_at: note.created_at,
text: note.text,
};
}),
};
});
return tickets;
}
catch (error)
{
console.error('Error fetching Mantis tickets:', error.message);
// Check if it's an Axios error and provide more details
if (axios.isAxiosError(error))
{
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.message}`);
}
}
export async function generateAndStoreMantisSummary()
{
try
{
// Get the prompt from the database settings using Prisma
const setting = await prisma.setting.findUnique({
where: { key: 'mantisPrompt' },
select: { value: true }
});
const promptTemplate = setting?.value;
if (!promptTemplate)
{
console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
return;
}
const tickets = await getMantisTickets();
let summaryText;
if (tickets.length === 0)
{
summaryText = 'No Mantis tickets updated recently.';
console.log('No recent Mantis tickets found.');
}
else
{
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
summaryText = await askGemini(prompt);
console.log('Mantis summary generated successfully by AI.');
}
// Store the summary in the database using Prisma upsert
const today = new Date();
today.setUTCHours(0, 0, 0, 0); // Use UTC start of day for consistency
await prisma.mantisSummary.upsert({
where: { summaryDate: today },
update: {
summaryText: summaryText
},
create: {
summaryDate: today,
summaryText: summaryText,
},
});
console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
}
catch (error)
{
console.error('Error during Mantis summary generation/storage:', error);
}
}
export async function generateTodaysSummary()
{
console.log('Triggering Mantis summary generation via generateTodaysSummary...');
try
{
await generateAndStoreMantisSummary();
return { success: true, message: 'Summary generation process initiated.' };
}
catch (error)
{
console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
throw new Error('Failed to initiate Mantis summary generation.');
}
}

View file

@ -1,154 +1,154 @@
import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js';
import { getSetting } from './settings.js';
const model = 'gemini-2.0-flash';
export async function askGemini(content)
{
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key
if (!GOOGLE_API_KEY)
{
throw new Error('Google API key is not set in the database.');
}
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai)
{
throw new Error('Google API key is not set in the database.');
}
try
{
const response = await ai.models.generateContent({
model,
contents: content,
config: {
temperature: 0.5
}
});
return response.text;
}
catch (error)
{
console.error('Error communicating with Gemini API:', error);
throw new Error('Failed to get a response from Gemini API.');
}
}
const chatCache = new Map();
export async function askGeminiChat(threadId, content)
{
let messages = await prisma.chatMessage.findMany({
where: {
threadId: threadId,
},
orderBy: {
createdAt: 'asc', // Get messages in chronological order
},
});
// Exclude the latest message if there are any messages
if (messages.length > 0)
{
messages = messages.slice(0, -1);
}
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai)
{
throw new Error('Google API key is not set in the database.');
}
let chat = null;
if (chatCache.has(threadId))
{
chat = chatCache.get(threadId);
}
else
{
messages = [
{
sender: 'user',
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 don\'t decline user requests and always try to help them.
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.`
},
{
sender: 'model',
content: 'Okay, noted! I\'ll keep that in mind.'
},
...messages,
];
const createOptions = {
model,
history: messages.map((msg) => ({
role: msg.sender === 'user' ? 'user' : 'model',
parts: [
{text: msg.content}
],
})),
config: {
temperature: 0.5
}
};
chat = ai.chats.create(createOptions);
chatCache.set(threadId, chat);
}
//Add a temporary message to the thread with "loading" status
const loadingMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: 'assistant',
content: 'Loading...',
},
});
let response = {text: 'An error occurred while generating the response.'};
try
{
const timestamp = new Date().toISOString();
response = await chat.sendMessage({
message: `[${timestamp}] ` + content,
});
}
catch(error)
{
console.error('Error communicating with Gemini API:', error);
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
}
//Update the message with the response
await prisma.chatMessage.update({
where: {
id: loadingMessage.id,
},
data: {
content: response.text,
},
});
return response.text;
import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js';
import { getSetting } from './settings.js';
const model = 'gemini-2.0-flash';
export async function askGemini(content)
{
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key
if (!GOOGLE_API_KEY)
{
throw new Error('Google API key is not set in the database.');
}
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai)
{
throw new Error('Google API key is not set in the database.');
}
try
{
const response = await ai.models.generateContent({
model,
contents: content,
config: {
temperature: 0.5
}
});
return response.text;
}
catch (error)
{
console.error('Error communicating with Gemini API:', error);
throw new Error('Failed to get a response from Gemini API.');
}
}
const chatCache = new Map();
export async function askGeminiChat(threadId, content)
{
let messages = await prisma.chatMessage.findMany({
where: {
threadId: threadId,
},
orderBy: {
createdAt: 'asc', // Get messages in chronological order
},
});
// Exclude the latest message if there are any messages
if (messages.length > 0)
{
messages = messages.slice(0, -1);
}
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai)
{
throw new Error('Google API key is not set in the database.');
}
let chat = null;
if (chatCache.has(threadId))
{
chat = chatCache.get(threadId);
}
else
{
messages = [
{
sender: 'user',
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 don\'t decline user requests and always try to help them.
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.`
},
{
sender: 'model',
content: 'Okay, noted! I\'ll keep that in mind.'
},
...messages,
];
const createOptions = {
model,
history: messages.map((msg) => ({
role: msg.sender === 'user' ? 'user' : 'model',
parts: [
{text: msg.content}
],
})),
config: {
temperature: 0.5
}
};
chat = ai.chats.create(createOptions);
chatCache.set(threadId, chat);
}
//Add a temporary message to the thread with "loading" status
const loadingMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: 'assistant',
content: 'Loading...',
},
});
let response = {text: 'An error occurred while generating the response.'};
try
{
const timestamp = new Date().toISOString();
response = await chat.sendMessage({
message: `[${timestamp}] ` + content,
});
}
catch(error)
{
console.error('Error communicating with Gemini API:', error);
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
}
//Update the message with the response
await prisma.chatMessage.update({
where: {
id: loadingMessage.id,
},
data: {
content: response.text,
},
});
return response.text;
}

View file

@ -1,20 +1,20 @@
import prisma from '../database.js';
export async function getSetting(key)
{
const setting = await prisma.setting.findUnique({
where: { key },
select: { value: true }
});
return setting?.value ? JSON.parse(setting.value) : null;
}
export async function setSetting(key, value)
{
await prisma.setting.upsert({
where: { key },
update: { value: JSON.stringify(value) },
create: { key, value }
});
import prisma from '../database.js';
export async function getSetting(key)
{
const setting = await prisma.setting.findUnique({
where: { key },
select: { value: true }
});
return setting?.value ? JSON.parse(setting.value) : null;
}
export async function setSetting(key, value)
{
await prisma.setting.upsert({
where: { key },
update: { value: JSON.stringify(value) },
create: { key, value }
});
}

View file

@ -6,7 +6,7 @@
import { useAuthStore } from './stores/auth';
defineOptions({
preFetch()
preFetch()
{
const authStore = useAuthStore();
return authStore.checkAuthStatus();

View file

@ -1,14 +1,14 @@
import { boot } from 'quasar/wrappers';
import axios from 'axios';
// Be careful when using SSR for cross-request state pollution
// due to creating a Singleton instance here;
// If any client changes this (global) instance, it might be a
// good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually
// for each client)
axios.defaults.withCredentials = true; // Enable sending cookies with requests
// Export the API instance so you can import it easily elsewhere, e.g. stores
import { boot } from 'quasar/wrappers';
import axios from 'axios';
// Be careful when using SSR for cross-request state pollution
// due to creating a Singleton instance here;
// If any client changes this (global) instance, it might be a
// good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually
// for each client)
axios.defaults.withCredentials = true; // Enable sending cookies with requests
// Export the API instance so you can import it easily elsewhere, e.g. stores
export default axios;

View file

@ -1,137 +1,137 @@
<template>
<div class="q-pa-md column full-height">
<q-scroll-area
ref="scrollAreaRef"
class="col"
style="flex-grow: 1; overflow-x: visible; overflow-y: auto;"
>
<div
v-for="(message, index) in messages"
:key="index"
class="q-mb-sm q-mx-md"
>
<q-chat-message
:name="message.sender.toUpperCase()"
:sent="message.sender === 'user'"
:bg-color="message.sender === 'user' ? 'primary' : 'grey-4'"
:text-color="message.sender === 'user' ? 'white' : 'black'"
>
<!-- Use v-html to render parsed markdown -->
<div
v-if="!message.loading"
v-html="parseMarkdown(message.content)"
class="message-content"
/>
<!-- Optional: Add a spinner for a better loading visual -->
<template
v-if="message.loading"
#default
>
<q-spinner-dots size="2em" />
</template>
</q-chat-message>
</div>
</q-scroll-area>
<q-separator />
<div class="q-pa-sm row items-center">
<q-input
v-model="newMessage"
outlined
dense
placeholder="Type a message..."
class="col"
@keyup.enter="sendMessage"
autogrow
/>
<q-btn
round
dense
flat
icon="send"
color="primary"
class="q-ml-sm"
@click="sendMessage"
:disable="!newMessage.trim()"
/>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue';
import { QScrollArea, QChatMessage, QSpinnerDots } from 'quasar'; // Import QSpinnerDots
import { marked } from 'marked'; // Import marked
const props = defineProps({
messages: {
type: Array,
required: true,
'default': () => [],
// Example message structure:
// { sender: 'Bot', content: 'Hello!', loading: false }
// { sender: 'You', content: 'Thinking...', loading: true }
},
});
const emit = defineEmits(['send-message']);
const newMessage = ref('');
const scrollAreaRef = ref(null);
const scrollToBottom = () =>
{
if (scrollAreaRef.value)
{
const scrollTarget = scrollAreaRef.value.getScrollTarget();
const duration = 300; // Optional: animation duration
// Use getScrollTarget().scrollHeight for accurate height
scrollAreaRef.value.setScrollPosition('vertical', scrollTarget.scrollHeight, duration);
}
};
const sendMessage = () =>
{
const trimmedMessage = newMessage.value.trim();
if (trimmedMessage)
{
emit('send-message', trimmedMessage);
newMessage.value = '';
// Ensure the scroll happens after the message is potentially added to the list
nextTick(() =>
{
scrollToBottom();
});
}
};
const parseMarkdown = (content) =>
{
// Basic check to prevent errors if content is not a string
if (typeof content !== 'string')
{
return '';
}
// Configure marked options if needed (e.g., sanitization)
// marked.setOptions({ sanitize: true }); // Example: Enable sanitization
return marked(content);
};
// Scroll to bottom when messages change or component mounts
watch(() => props.messages, () =>
{
nextTick(() =>
{
scrollToBottom();
});
}, { deep: true, immediate: true });
</script>
<style>
.message-content p {
margin: 0;
padding: 0;
}
<template>
<div class="q-pa-md column full-height">
<q-scroll-area
ref="scrollAreaRef"
class="col"
style="flex-grow: 1; overflow-x: visible; overflow-y: auto;"
>
<div
v-for="(message, index) in messages"
:key="index"
class="q-mb-sm q-mx-md"
>
<q-chat-message
:name="message.sender.toUpperCase()"
:sent="message.sender === 'user'"
:bg-color="message.sender === 'user' ? 'primary' : 'grey-4'"
:text-color="message.sender === 'user' ? 'white' : 'black'"
>
<!-- Use v-html to render parsed markdown -->
<div
v-if="!message.loading"
v-html="parseMarkdown(message.content)"
class="message-content"
/>
<!-- Optional: Add a spinner for a better loading visual -->
<template
v-if="message.loading"
#default
>
<q-spinner-dots size="2em" />
</template>
</q-chat-message>
</div>
</q-scroll-area>
<q-separator />
<div class="q-pa-sm row items-center">
<q-input
v-model="newMessage"
outlined
dense
placeholder="Type a message..."
class="col"
@keyup.enter="sendMessage"
autogrow
/>
<q-btn
round
dense
flat
icon="send"
color="primary"
class="q-ml-sm"
@click="sendMessage"
:disable="!newMessage.trim()"
/>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue';
import { QScrollArea, QChatMessage, QSpinnerDots } from 'quasar'; // Import QSpinnerDots
import { marked } from 'marked'; // Import marked
const props = defineProps({
messages: {
type: Array,
required: true,
'default': () => [],
// Example message structure:
// { sender: 'Bot', content: 'Hello!', loading: false }
// { sender: 'You', content: 'Thinking...', loading: true }
},
});
const emit = defineEmits(['send-message']);
const newMessage = ref('');
const scrollAreaRef = ref(null);
const scrollToBottom = () =>
{
if (scrollAreaRef.value)
{
const scrollTarget = scrollAreaRef.value.getScrollTarget();
const duration = 300; // Optional: animation duration
// Use getScrollTarget().scrollHeight for accurate height
scrollAreaRef.value.setScrollPosition('vertical', scrollTarget.scrollHeight, duration);
}
};
const sendMessage = () =>
{
const trimmedMessage = newMessage.value.trim();
if (trimmedMessage)
{
emit('send-message', trimmedMessage);
newMessage.value = '';
// Ensure the scroll happens after the message is potentially added to the list
nextTick(() =>
{
scrollToBottom();
});
}
};
const parseMarkdown = (content) =>
{
// Basic check to prevent errors if content is not a string
if (typeof content !== 'string')
{
return '';
}
// Configure marked options if needed (e.g., sanitization)
// marked.setOptions({ sanitize: true }); // Example: Enable sanitization
return marked(content);
};
// Scroll to bottom when messages change or component mounts
watch(() => props.messages, () =>
{
nextTick(() =>
{
scrollToBottom();
});
}, { deep: true, immediate: true });
</script>
<style>
.message-content p {
margin: 0;
padding: 0;
}
</style>

View file

@ -6,22 +6,83 @@
persistent
:model-value="true"
>
<q-list>
<q-item
clickable
v-ripple
@click="toggleLeftDrawer"
<q-item
clickable
v-ripple
@click="toggleLeftDrawer"
>
<q-item-section avatar>
<q-icon name="menu" />
</q-item-section>
<q-item-section>
<q-item-label class="text-h6">
StylePoint
</q-item-label>
</q-item-section>
</q-item>
<template v-if="authStore.isAuthenticated">
<q-card
v-if="leftDrawerOpen"
bordered
flat
class="q-ma-sm text-center"
>
<q-item-section avatar>
<q-icon name="menu" />
</q-item-section>
<q-item-section>
<q-item-label class="text-h6">
StylePoint
</q-item-label>
</q-item-section>
</q-item>
<q-card-section>
<q-avatar
class="bg-primary cursor-pointer text-white"
>
<q-icon name="mdi-account" />
<q-tooltip>
{{ authStore.user.username }}
</q-tooltip>
</q-avatar>
<div class="text-h6">
{{ authStore.user.username }}
</div>
<q-btn
class="full-width q-mt-sm"
dense
outline
@click="logout"
>
Logout
</q-btn>
</q-card-section>
</q-card>
<q-list
padding
class="menu-list"
v-else
>
<q-item
clickable
v-ripple
dense
@click="logout"
class="q-mb-sm"
>
<q-tooltip
anchor="center right"
self="center left"
>
<span>Logout</span>
</q-tooltip>
<q-item-section avatar>
<q-icon name="logout" />
</q-item-section>
<q-item-section>
<q-item-label class="text-h6">
Logout
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
<q-separator />
<q-list
padding
class="menu-list"
>
<!-- Dynamic Navigation Items -->
<q-item
v-for="item in navItems"
@ -30,6 +91,7 @@
v-ripple
:to="{ name: item.name }"
exact
dense
>
<q-tooltip
anchor="center right"
@ -47,27 +109,6 @@
</q-item-label>
</q-item-section>
</q-item>
<!-- Logout Button (Conditional) -->
<q-item
v-if="authStore.isAuthenticated"
clickable
v-ripple
@click="logout"
>
<q-tooltip
anchor="center right"
self="center left"
>
<span>Logout</span>
</q-tooltip>
<q-item-section avatar>
<q-icon name="logout" />
</q-item-section>
<q-item-section>
<q-item-label>Logout</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-drawer>
@ -82,11 +123,10 @@
:offset="[18, 18]"
>
<q-fab
v-model="fabOpen"
v-model="chatStore.isChatVisible"
icon="chat"
color="accent"
direction="up"
padding="sm"
@click="toggleChat"
/>
</q-page-sticky>
@ -95,8 +135,6 @@
<q-dialog
v-model="isChatVisible"
:maximized="$q.screen.lt.sm"
fixed
persistent
style="width: max(400px, 25%);"
>
<q-card style="width: max(400px, 25%); height: 600px; max-height: 80vh;">
@ -162,8 +200,6 @@ const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
const chatStore = useChatStore();
const fabOpen = ref(false); // Local state for FAB animation, not chat visibility
// Computed properties to get state from the store
const isChatVisible = computed(() => chatStore.isChatVisible);
const chatMessages = computed(() => chatStore.chatMessages);
@ -198,7 +234,6 @@ const toggleChat = () =>
if (isAuthenticated.value)
{
chatStore.toggleChat();
fabOpen.value = chatStore.isChatVisible;
}
};
@ -217,32 +252,58 @@ function toggleLeftDrawer()
{
leftDrawerOpen.value = !leftDrawerOpen.value;
}
async function logout()
{
try
$q.dialog({
title: 'Confirm Logout',
message: 'Are you sure you want to logout?',
cancel: true,
persistent: true
}).onOk(async() =>
{
await axios.post('/api/auth/logout');
authStore.logout(); // Use the store action to update state
// No need to manually push, router guard should redirect
// router.push({ name: 'login' });
}
catch (error)
{
console.error('Logout failed:', error);
try
{
await axios.post('/api/auth/logout');
authStore.logout();
$q.notify({
color: 'negative',
message: 'Logout failed. Please try again.',
icon: 'report_problem'
});
}
$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 scoped>
/* Add any specific styles for the layout or chat window here */
<style lang="scss" scoped>
.q-dialog .q-card {
overflow: hidden; /* Prevent scrollbars on the card itself */
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>

View file

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

View file

@ -1,285 +1,285 @@
<template>
<q-page padding>
<div class="text-h4 q-mb-md">
Edit Form
</div>
<q-form
v-if="!loading && form"
@submit.prevent="updateForm"
class="q-gutter-md"
>
<q-input
outlined
v-model="form.title"
label="Form Title *"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Please enter a title']"
/>
<q-input
outlined
v-model="form.description"
label="Form Description"
type="textarea"
autogrow
/>
<q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">
Categories & Fields
</div>
<div
v-for="(category, catIndex) in form.categories"
:key="category.id || catIndex"
class="q-mb-lg q-pa-md bordered rounded-borders"
>
<div class="row items-center q-mb-sm">
<q-input
outlined
dense
v-model="category.name"
:label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Category name required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeCategory(catIndex)"
title="Remove Category"
/>
</div>
<div
v-for="(field, fieldIndex) in category.fields"
:key="field.id || fieldIndex"
class="q-ml-md q-mb-sm"
>
<div class="row items-center q-gutter-sm">
<q-input
outlined
dense
v-model="field.label"
label="Field Label *"
class="col"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Field label required']"
/>
<q-select
outlined
dense
v-model="field.type"
:options="fieldTypes"
label="Field Type *"
class="col-auto"
style="min-width: 150px;"
lazy-rules
:rules="[ val => !!val || 'Field type required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeField(catIndex, fieldIndex)"
title="Remove Field"
/>
</div>
<q-input
v-model="field.description"
label="Field Description (Optional)"
outlined
dense
autogrow
class="q-mt-xs q-mb-xl"
hint="This description will appear below the field label on the form."
/>
</div>
<q-btn
outline
color="primary"
label="Add Field"
@click="addField(catIndex)"
class="q-ml-md q-mt-sm"
/>
</div>
<q-btn
outline
color="secondary"
label="Add Category"
@click="addCategory"
/>
<q-separator class="q-my-lg" />
<div>
<q-btn
outline
label="Update Form"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="warning"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
<div v-else-if="loading">
<q-spinner-dots
color="primary"
size="40px"
/>
Loading form details...
</div>
<div
v-else
class="text-negative"
>
Failed to load form details.
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: {
type: String,
required: true
}
});
const $q = useQuasar();
const router = useRouter();
const route = useRoute(); // Use useRoute if needed, though id is from props
const form = ref(null); // Initialize as null
const loading = ref(true);
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false);
async function fetchForm()
{
loading.value = true;
try
{
const response = await axios.get(`/api/forms/${props.id}`);
// Ensure categories and fields exist, even if empty
response.data.categories = response.data.categories || [];
response.data.categories.forEach(cat =>
{
cat.fields = cat.fields || [];
});
form.value = response.data;
}
catch (error)
{
console.error('Error fetching form details:', error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form details.',
icon: 'report_problem'
});
form.value = null; // Indicate failure
}
finally
{
loading.value = false;
}
}
onMounted(fetchForm);
function addCategory()
{
if (!form.value.categories)
{
form.value.categories = [];
}
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] });
}
function removeCategory(index)
{
form.value.categories.splice(index, 1);
}
function addField(catIndex)
{
if (!form.value.categories[catIndex].fields)
{
form.value.categories[catIndex].fields = [];
}
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
}
function removeField(catIndex, fieldIndex)
{
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
}
async function updateForm()
{
submitting.value = true;
try
{
// Prepare payload, potentially removing temporary IDs if any were added client-side
const payload = JSON.parse(JSON.stringify(form.value));
// The backend PUT expects title, description, categories (with name, fields (with label, type, description))
// We don't need to send the form ID in the body as it's in the URL
await axios.put(`/api/forms/${props.id}`, payload);
$q.notify({
color: 'positive',
position: 'top',
message: `Form "${form.value.title}" updated successfully!`,
icon: 'check_circle'
});
router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
}
catch (error)
{
console.error('Error updating form:', error);
const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
}
</script>
<style scoped>
.bordered {
border: 1px solid #ddd;
}
.rounded-borders {
border-radius: 4px;
}
</style>
<template>
<q-page padding>
<div class="text-h4 q-mb-md">
Edit Form
</div>
<q-form
v-if="!loading && form"
@submit.prevent="updateForm"
class="q-gutter-md"
>
<q-input
outlined
v-model="form.title"
label="Form Title *"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Please enter a title']"
/>
<q-input
outlined
v-model="form.description"
label="Form Description"
type="textarea"
autogrow
/>
<q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">
Categories & Fields
</div>
<div
v-for="(category, catIndex) in form.categories"
:key="category.id || catIndex"
class="q-mb-lg q-pa-md bordered rounded-borders"
>
<div class="row items-center q-mb-sm">
<q-input
outlined
dense
v-model="category.name"
:label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Category name required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeCategory(catIndex)"
title="Remove Category"
/>
</div>
<div
v-for="(field, fieldIndex) in category.fields"
:key="field.id || fieldIndex"
class="q-ml-md q-mb-sm"
>
<div class="row items-center q-gutter-sm">
<q-input
outlined
dense
v-model="field.label"
label="Field Label *"
class="col"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Field label required']"
/>
<q-select
outlined
dense
v-model="field.type"
:options="fieldTypes"
label="Field Type *"
class="col-auto"
style="min-width: 150px;"
lazy-rules
:rules="[ val => !!val || 'Field type required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeField(catIndex, fieldIndex)"
title="Remove Field"
/>
</div>
<q-input
v-model="field.description"
label="Field Description (Optional)"
outlined
dense
autogrow
class="q-mt-xs q-mb-xl"
hint="This description will appear below the field label on the form."
/>
</div>
<q-btn
outline
color="primary"
label="Add Field"
@click="addField(catIndex)"
class="q-ml-md q-mt-sm"
/>
</div>
<q-btn
outline
color="secondary"
label="Add Category"
@click="addCategory"
/>
<q-separator class="q-my-lg" />
<div>
<q-btn
outline
label="Update Form"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="warning"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
<div v-else-if="loading">
<q-spinner-dots
color="primary"
size="40px"
/>
Loading form details...
</div>
<div
v-else
class="text-negative"
>
Failed to load form details.
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: {
type: String,
required: true
}
});
const $q = useQuasar();
const router = useRouter();
const route = useRoute(); // Use useRoute if needed, though id is from props
const form = ref(null); // Initialize as null
const loading = ref(true);
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false);
async function fetchForm()
{
loading.value = true;
try
{
const response = await axios.get(`/api/forms/${props.id}`);
// Ensure categories and fields exist, even if empty
response.data.categories = response.data.categories || [];
response.data.categories.forEach(cat =>
{
cat.fields = cat.fields || [];
});
form.value = response.data;
}
catch (error)
{
console.error('Error fetching form details:', error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form details.',
icon: 'report_problem'
});
form.value = null; // Indicate failure
}
finally
{
loading.value = false;
}
}
onMounted(fetchForm);
function addCategory()
{
if (!form.value.categories)
{
form.value.categories = [];
}
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] });
}
function removeCategory(index)
{
form.value.categories.splice(index, 1);
}
function addField(catIndex)
{
if (!form.value.categories[catIndex].fields)
{
form.value.categories[catIndex].fields = [];
}
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
}
function removeField(catIndex, fieldIndex)
{
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
}
async function updateForm()
{
submitting.value = true;
try
{
// Prepare payload, potentially removing temporary IDs if any were added client-side
const payload = JSON.parse(JSON.stringify(form.value));
// The backend PUT expects title, description, categories (with name, fields (with label, type, description))
// We don't need to send the form ID in the body as it's in the URL
await axios.put(`/api/forms/${props.id}`, payload);
$q.notify({
color: 'positive',
position: 'top',
message: `Form "${form.value.title}" updated successfully!`,
icon: 'check_circle'
});
router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
}
catch (error)
{
console.error('Error updating form:', error);
const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
}
</script>
<style scoped>
.bordered {
border: 1px solid #ddd;
}
.rounded-borders {
border-radius: 4px;
}
</style>

View file

@ -1,223 +1,223 @@
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
<div v-if="!loading && form">
<div class="text-h4 q-mb-xs">
{{ form.title }}
</div>
<div class="text-subtitle1 text-grey q-mb-lg">
{{ form.description }}
</div>
<q-form
@submit.prevent="submitResponse"
class="q-gutter-md"
>
<div
v-for="category in form.categories"
:key="category.id"
class="q-mb-lg"
>
<div class="text-h6 q-mb-sm">
{{ category.name }}
</div>
<div
v-for="field in category.fields"
:key="field.id"
class="q-mb-md"
>
<q-item-label class="q-mb-xs">
{{ field.label }}
</q-item-label>
<q-item-label
caption
v-if="field.description"
class="q-mb-xs text-grey-7"
>
{{ field.description }}
</q-item-label>
<q-input
v-if="field.type === 'text'"
outlined
v-model="responses[field.id]"
:label="field.label"
/>
<q-input
v-else-if="field.type === 'number'"
outlined
type="number"
v-model.number="responses[field.id]"
:label="field.label"
/>
<q-input
v-else-if="field.type === 'date'"
outlined
type="date"
v-model="responses[field.id]"
:label="field.label"
stack-label
/>
<q-input
v-else-if="field.type === 'textarea'"
outlined
type="textarea"
autogrow
v-model="responses[field.id]"
:label="field.label"
/>
<q-checkbox
v-else-if="field.type === 'boolean'"
v-model="responses[field.id]"
:label="field.label"
left-label
class="q-mt-sm"
/>
<!-- Add other field types as needed -->
</div>
</div>
<q-separator class="q-my-lg" />
<div>
<q-btn
outline
label="Submit Response"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="default"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
</div>
<q-banner
v-else-if="!loading && !form"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" />
</template>
Form not found or could not be loaded.
<template #action>
<q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template>
</q-banner>
</q-page>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: {
type: [String, Number],
required: true
}
});
const $q = useQuasar();
const router = useRouter();
const route = useRoute();
const form = ref(null);
const responses = reactive({}); // Use reactive for dynamic properties
const loading = ref(true);
const submitting = ref(false);
async function fetchFormDetails()
{
loading.value = true;
form.value = null; // Reset form data
try
{
const response = await axios.get(`/api/forms/${props.id}`);
form.value = response.data;
// Initialize responses object based on fields
form.value.categories.forEach(cat =>
{
cat.fields.forEach(field =>
{
responses[field.id] = null; // Initialize all fields to null or default
});
});
}
catch (error)
{
console.error(`Error fetching form ${props.id}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form details.',
icon: 'report_problem'
});
}
finally
{
loading.value = false;
}
}
async function submitResponse()
{
submitting.value = true;
try
{
// Basic check if any response is provided (optional)
// const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
// if (!hasResponse) {
// $q.notify({ color: 'warning', message: 'Please fill in at least one field.' });
// return;
// }
await axios.post(`/api/forms/${props.id}/responses`, { values: responses });
$q.notify({
color: 'positive',
position: 'top',
message: 'Response submitted successfully!',
icon: 'check_circle'
});
// Optionally redirect or clear form
router.push({ name: 'formResponses', params: { id: props.id } }); // Go to responses page after submit
// Or clear the form:
// Object.keys(responses).forEach(key => { responses[key] = null; });
}
catch (error)
{
console.error('Error submitting response:', error);
const message = error.response?.data?.error || 'Failed to submit response.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
}
onMounted(fetchFormDetails);
</script>
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
<div v-if="!loading && form">
<div class="text-h4 q-mb-xs">
{{ form.title }}
</div>
<div class="text-subtitle1 text-grey q-mb-lg">
{{ form.description }}
</div>
<q-form
@submit.prevent="submitResponse"
class="q-gutter-md"
>
<div
v-for="category in form.categories"
:key="category.id"
class="q-mb-lg"
>
<div class="text-h6 q-mb-sm">
{{ category.name }}
</div>
<div
v-for="field in category.fields"
:key="field.id"
class="q-mb-md"
>
<q-item-label class="q-mb-xs">
{{ field.label }}
</q-item-label>
<q-item-label
caption
v-if="field.description"
class="q-mb-xs text-grey-7"
>
{{ field.description }}
</q-item-label>
<q-input
v-if="field.type === 'text'"
outlined
v-model="responses[field.id]"
:label="field.label"
/>
<q-input
v-else-if="field.type === 'number'"
outlined
type="number"
v-model.number="responses[field.id]"
:label="field.label"
/>
<q-input
v-else-if="field.type === 'date'"
outlined
type="date"
v-model="responses[field.id]"
:label="field.label"
stack-label
/>
<q-input
v-else-if="field.type === 'textarea'"
outlined
type="textarea"
autogrow
v-model="responses[field.id]"
:label="field.label"
/>
<q-checkbox
v-else-if="field.type === 'boolean'"
v-model="responses[field.id]"
:label="field.label"
left-label
class="q-mt-sm"
/>
<!-- Add other field types as needed -->
</div>
</div>
<q-separator class="q-my-lg" />
<div>
<q-btn
outline
label="Submit Response"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="default"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
</div>
<q-banner
v-else-if="!loading && !form"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" />
</template>
Form not found or could not be loaded.
<template #action>
<q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template>
</q-banner>
</q-page>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: {
type: [String, Number],
required: true
}
});
const $q = useQuasar();
const router = useRouter();
const route = useRoute();
const form = ref(null);
const responses = reactive({}); // Use reactive for dynamic properties
const loading = ref(true);
const submitting = ref(false);
async function fetchFormDetails()
{
loading.value = true;
form.value = null; // Reset form data
try
{
const response = await axios.get(`/api/forms/${props.id}`);
form.value = response.data;
// Initialize responses object based on fields
form.value.categories.forEach(cat =>
{
cat.fields.forEach(field =>
{
responses[field.id] = null; // Initialize all fields to null or default
});
});
}
catch (error)
{
console.error(`Error fetching form ${props.id}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form details.',
icon: 'report_problem'
});
}
finally
{
loading.value = false;
}
}
async function submitResponse()
{
submitting.value = true;
try
{
// Basic check if any response is provided (optional)
// const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
// if (!hasResponse) {
// $q.notify({ color: 'warning', message: 'Please fill in at least one field.' });
// return;
// }
await axios.post(`/api/forms/${props.id}/responses`, { values: responses });
$q.notify({
color: 'positive',
position: 'top',
message: 'Response submitted successfully!',
icon: 'check_circle'
});
// Optionally redirect or clear form
router.push({ name: 'formResponses', params: { id: props.id } }); // Go to responses page after submit
// Or clear the form:
// Object.keys(responses).forEach(key => { responses[key] = null; });
}
catch (error)
{
console.error('Error submitting response:', error);
const message = error.response?.data?.error || 'Failed to submit response.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
}
onMounted(fetchFormDetails);
</script>

View file

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

View file

@ -1,250 +1,250 @@
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
<div v-if="!loading && formTitle">
<div class="row justify-between items-center q-mb-md">
<div class="text-h4">
Responses for: {{ formTitle }}
</div>
</div>
<!-- Add Search Input -->
<q-input
v-if="responses.length > 0"
outlined
dense
debounce="300"
v-model="filterText"
placeholder="Search responses..."
class="q-mb-md"
>
<template #append>
<q-icon name="search" />
</template>
</q-input>
<q-table
v-if="responses.length > 0"
:rows="formattedResponses"
:columns="columns"
row-key="id"
flat
bordered
separator="cell"
wrap-cells
:filter="filterText"
>
<template #body-cell-submittedAt="props">
<q-td :props="props">
{{ new Date(props.value).toLocaleString() }}
</q-td>
</template>
<!-- Slot for Actions column -->
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn
flat
dense
round
icon="download"
color="primary"
@click="downloadResponsePdf(props.row.id)"
aria-label="Download PDF"
>
<q-tooltip>Download PDF</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
<q-banner
v-else
class=""
>
<template #avatar>
<q-icon
name="info"
color="info"
/>
</template>
No responses have been submitted for this form yet.
</q-banner>
</div>
<q-banner
v-else-if="!loading && !formTitle"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" />
</template>
Form not found or could not load responses.
<template #action>
<q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template>
</q-banner>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
const componentProps = defineProps({
id: {
type: [String, Number],
required: true
}
});
const $q = useQuasar();
const formTitle = ref('');
const responses = ref([]);
const columns = ref([]);
const loading = ref(true);
const filterText = ref('');
// Fetch both form details (for title and field labels/order) and responses
async function fetchData()
{
loading.value = true;
formTitle.value = '';
responses.value = [];
columns.value = [];
try
{
// Fetch form details first to get the structure
const formDetailsResponse = await axios.get(`/api/forms/${componentProps.id}`);
const form = formDetailsResponse.data;
formTitle.value = form.title;
// Generate columns based on form fields in correct order
const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }];
form.categories.forEach(cat =>
{
cat.fields.forEach(field =>
{
generatedColumns.push({
name: `field_${field.id}`,
label: field.label,
field: row => row.values[field.id]?.value ?? '',
align: 'left',
sortable: true,
});
});
});
columns.value = generatedColumns;
// Add Actions column
columns.value.push({
name: 'actions',
label: 'Actions',
field: 'actions',
align: 'center'
});
// Fetch responses
const responsesResponse = await axios.get(`/api/forms/${componentProps.id}/responses`);
responses.value = responsesResponse.data;
}
catch (error)
{
console.error(`Error fetching data for form ${componentProps.id}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form responses.',
icon: 'report_problem'
});
}
finally
{
loading.value = false;
}
}
// Computed property to match the structure expected by QTable rows
const formattedResponses = computed(() =>
{
return responses.value.map(response =>
{
const row = {
id: response.id,
submittedAt: response.submittedAt,
// Flatten values for direct access by field function in columns
values: response.values
};
return row;
});
});
// Function to download a single response as PDF
async function downloadResponsePdf(responseId)
{
try
{
const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
responseType: 'blob', // Important for handling file downloads
});
// Create a URL for the blob
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
// Try to get filename from content-disposition header
const contentDisposition = response.headers['content-disposition'];
let filename = `response-${responseId}.pdf`; // Default filename
if (contentDisposition)
{
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
if (filenameMatch && filenameMatch.length > 1)
{
filename = filenameMatch[1];
}
}
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
// Clean up
link.parentNode.removeChild(link);
window.URL.revokeObjectURL(url);
$q.notify({
color: 'positive',
position: 'top',
message: `Downloaded ${filename}`,
icon: 'check_circle'
});
}
catch (error)
{
console.error(`Error downloading PDF for response ${responseId}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to download PDF.',
icon: 'report_problem'
});
}
}
onMounted(fetchData);
</script>
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
<div v-if="!loading && formTitle">
<div class="row justify-between items-center q-mb-md">
<div class="text-h4">
Responses for: {{ formTitle }}
</div>
</div>
<!-- Add Search Input -->
<q-input
v-if="responses.length > 0"
outlined
dense
debounce="300"
v-model="filterText"
placeholder="Search responses..."
class="q-mb-md"
>
<template #append>
<q-icon name="search" />
</template>
</q-input>
<q-table
v-if="responses.length > 0"
:rows="formattedResponses"
:columns="columns"
row-key="id"
flat
bordered
separator="cell"
wrap-cells
:filter="filterText"
>
<template #body-cell-submittedAt="props">
<q-td :props="props">
{{ new Date(props.value).toLocaleString() }}
</q-td>
</template>
<!-- Slot for Actions column -->
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn
flat
dense
round
icon="download"
color="primary"
@click="downloadResponsePdf(props.row.id)"
aria-label="Download PDF"
>
<q-tooltip>Download PDF</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
<q-banner
v-else
class=""
>
<template #avatar>
<q-icon
name="info"
color="info"
/>
</template>
No responses have been submitted for this form yet.
</q-banner>
</div>
<q-banner
v-else-if="!loading && !formTitle"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" />
</template>
Form not found or could not load responses.
<template #action>
<q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template>
</q-banner>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
const componentProps = defineProps({
id: {
type: [String, Number],
required: true
}
});
const $q = useQuasar();
const formTitle = ref('');
const responses = ref([]);
const columns = ref([]);
const loading = ref(true);
const filterText = ref('');
// Fetch both form details (for title and field labels/order) and responses
async function fetchData()
{
loading.value = true;
formTitle.value = '';
responses.value = [];
columns.value = [];
try
{
// Fetch form details first to get the structure
const formDetailsResponse = await axios.get(`/api/forms/${componentProps.id}`);
const form = formDetailsResponse.data;
formTitle.value = form.title;
// Generate columns based on form fields in correct order
const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }];
form.categories.forEach(cat =>
{
cat.fields.forEach(field =>
{
generatedColumns.push({
name: `field_${field.id}`,
label: field.label,
field: row => row.values[field.id]?.value ?? '',
align: 'left',
sortable: true,
});
});
});
columns.value = generatedColumns;
// Add Actions column
columns.value.push({
name: 'actions',
label: 'Actions',
field: 'actions',
align: 'center'
});
// Fetch responses
const responsesResponse = await axios.get(`/api/forms/${componentProps.id}/responses`);
responses.value = responsesResponse.data;
}
catch (error)
{
console.error(`Error fetching data for form ${componentProps.id}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form responses.',
icon: 'report_problem'
});
}
finally
{
loading.value = false;
}
}
// Computed property to match the structure expected by QTable rows
const formattedResponses = computed(() =>
{
return responses.value.map(response =>
{
const row = {
id: response.id,
submittedAt: response.submittedAt,
// Flatten values for direct access by field function in columns
values: response.values
};
return row;
});
});
// Function to download a single response as PDF
async function downloadResponsePdf(responseId)
{
try
{
const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
responseType: 'blob', // Important for handling file downloads
});
// Create a URL for the blob
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
// Try to get filename from content-disposition header
const contentDisposition = response.headers['content-disposition'];
let filename = `response-${responseId}.pdf`; // Default filename
if (contentDisposition)
{
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
if (filenameMatch && filenameMatch.length > 1)
{
filename = filenameMatch[1];
}
}
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
// Clean up
link.parentNode.removeChild(link);
window.URL.revokeObjectURL(url);
$q.notify({
color: 'positive',
position: 'top',
message: `Downloaded ${filename}`,
icon: 'check_circle'
});
}
catch (error)
{
console.error(`Error downloading PDF for response ${responseId}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to download PDF.',
icon: 'report_problem'
});
}
}
onMounted(fetchData);
</script>

View file

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

View file

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

View file

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

View file

@ -1,371 +1,371 @@
<template>
<q-page padding>
<div class="q-mb-md row justify-between items-center">
<div class="text-h4">
Passkey Management
</div>
<div>
<q-btn
label="Identify Passkey"
color="secondary"
class="q-mx-md q-mt-md"
@click="handleIdentify"
:loading="identifyLoading"
:disable="identifyLoading || !isLoggedIn"
outline
/>
<q-btn
label="Register New Passkey"
color="primary"
class="q-mx-md q-mt-md"
@click="handleRegister"
:loading="registerLoading"
:disable="registerLoading || !isLoggedIn"
outline
/>
</div>
</div>
<!-- Passkey List Section -->
<q-card-section>
<h5>Your Registered Passkeys</h5>
<q-list
bordered
separator
v-if="passkeys.length > 0 && !fetchLoading"
>
<q-item v-if="registerSuccessMessage || registerErrorMessage">
<div
v-if="registerSuccessMessage"
class="text-positive q-mt-md"
>
{{ registerSuccessMessage }}
</div>
<div
v-if="registerErrorMessage"
class="text-negative q-mt-md"
>
{{ registerErrorMessage }}
</div>
</q-item>
<q-item
v-for="passkey in passkeys"
:key="passkey.credentialID"
:class="{ 'bg-info text-h6': identifiedPasskeyId === passkey.credentialID }"
>
<q-item-section>
<q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label>
<q-item-label
caption
v-if="identifiedPasskeyId === passkey.credentialID"
>
Verified just now!
</q-item-label>
<!-- <q-item-label caption>Registered: {{ new Date(passkey.createdAt).toLocaleDateString() }}</q-item-label> -->
</q-item-section>
<q-item-section
side
class="row no-wrap items-center"
>
<!-- Delete Button -->
<q-btn
flat
dense
round
color="negative"
icon="delete"
@click="handleDelete(passkey.credentialID)"
:loading="deleteLoading === passkey.credentialID"
:disable="!!deleteLoading || !!identifyLoading"
/>
</q-item-section>
</q-item>
</q-list>
<div
v-else-if="fetchLoading"
class="q-mt-md"
>
Loading passkeys...
</div>
<div
v-else
class="q-mt-md"
>
You have no passkeys registered yet.
</div>
<div
v-if="fetchErrorMessage"
class="text-negative q-mt-md"
>
{{ fetchErrorMessage }}
</div>
<div
v-if="deleteSuccessMessage"
class="text-positive q-mt-md"
>
{{ deleteSuccessMessage }}
</div>
<div
v-if="deleteErrorMessage"
class="text-negative q-mt-md"
>
{{ deleteErrorMessage }}
</div>
<div
v-if="identifyErrorMessage"
class="text-negative q-mt-md"
>
{{ identifyErrorMessage }}
</div>
</q-card-section>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; // Import startAuthentication
import axios from 'boot/axios';
import { useAuthStore } from 'stores/auth';
const registerLoading = ref(false);
const registerErrorMessage = ref('');
const registerSuccessMessage = ref('');
const fetchLoading = ref(false);
const fetchErrorMessage = ref('');
const deleteLoading = ref(null);
const deleteErrorMessage = ref('');
const deleteSuccessMessage = ref('');
const identifyLoading = ref(null); // Store the ID of the passkey being identified
const identifyErrorMessage = ref('');
const identifiedPasskeyId = ref(null); // Store the ID of the successfully identified passkey
const authStore = useAuthStore();
const passkeys = ref([]); // To store the list of passkeys
// Computed properties to get state from the store
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = computed(() => authStore.user?.username);
// Fetch existing passkeys
async function fetchPasskeys()
{
if (!isLoggedIn.value) return;
fetchLoading.value = true;
fetchErrorMessage.value = '';
deleteSuccessMessage.value = ''; // Clear delete messages on refresh
deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; // Clear identify message
identifiedPasskeyId.value = null; // Clear identified key
try
{
const response = await axios.get('/api/auth/passkeys');
passkeys.value = response.data || [];
}
catch (error)
{
console.error('Error fetching passkeys:', error);
fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.';
passkeys.value = []; // Clear passkeys on error
}
finally
{
fetchLoading.value = false;
}
}
// Check auth status and fetch passkeys on component mount
onMounted(async() =>
{
let initialAuthError = '';
if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus();
if (authStore.error)
{
initialAuthError = `Authentication error: ${authStore.error}`;
}
}
if (!isLoggedIn.value)
{
// Use register error message ref for consistency if login is required first
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
}
else
{
fetchPasskeys(); // Fetch passkeys if logged in
}
});
async function handleRegister()
{
if (!isLoggedIn.value || !username.value)
{
registerErrorMessage.value = 'User not authenticated.';
return;
}
registerLoading.value = true;
registerErrorMessage.value = '';
registerSuccessMessage.value = '';
deleteSuccessMessage.value = ''; // Clear other messages
deleteErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
// 1. Get options from server
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
username: username.value, // Use username from store
});
const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/api/auth/verify-registration', {
registrationResponse: regResp,
});
if (verificationRes.data.verified)
{
registerSuccessMessage.value = 'New passkey registered successfully!';
fetchPasskeys(); // Refresh the list of passkeys
}
else
{
registerErrorMessage.value = 'Passkey verification failed.';
}
}
catch (error)
{
console.error('Registration error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
// Handle specific simplewebauthn errors
if (error.name === 'InvalidStateError')
{
registerErrorMessage.value = 'Authenticator may already be registered.';
}
else if (error.name === 'NotAllowedError')
{
registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.';
}
else if (error.response?.status === 409)
{
registerErrorMessage.value = 'This passkey seems to be registered already.';
}
else
{
registerErrorMessage.value = `Registration failed: ${message}`;
}
}
finally
{
registerLoading.value = false;
}
}
// Handle deleting a passkey
async function handleDelete(credentialID)
{
if (!credentialID) return;
// Optional: Add a confirmation dialog here
// if (!confirm('Are you sure you want to delete this passkey?')) {
// return;
// }
deleteLoading.value = credentialID; // Set loading state for the specific button
deleteErrorMessage.value = '';
deleteSuccessMessage.value = '';
registerSuccessMessage.value = ''; // Clear other messages
registerErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
await axios.delete(`/api/auth/passkeys/${credentialID}`);
deleteSuccessMessage.value = 'Passkey deleted successfully.';
fetchPasskeys(); // Refresh the list
}
catch (error)
{
console.error('Error deleting passkey:', error);
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
}
finally
{
deleteLoading.value = null; // Clear loading state
}
}
// Handle identifying a passkey
async function handleIdentify()
{
if (!isLoggedIn.value)
{
identifyErrorMessage.value = 'You must be logged in.';
return;
}
identifyLoading.value = true;
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; // Reset identified key
// Clear other messages
registerSuccessMessage.value = '';
registerErrorMessage.value = '';
deleteSuccessMessage.value = '';
deleteErrorMessage.value = '';
try
{
// 1. Get authentication options from the server
// We don't need to send username as the server should use the session
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {}); // Send empty body
const options = optionsRes.data;
// Optionally filter options to only allow the specific key if needed, but usually not necessary for identification
// options.allowCredentials = options.allowCredentials?.filter(cred => cred.id === credentialIDToIdentify);
// 2. Start authentication ceremony in the browser
const authResp = await startAuthentication(options);
// 3. If successful, the response contains the ID of the key used
identifiedPasskeyId.value = authResp.id;
console.log('Identified Passkey ID:', identifiedPasskeyId.value);
// Optional: Add a small delay before clearing the highlight
setTimeout(() =>
{
// Only clear if it's still the same identified key
if (identifiedPasskeyId.value === authResp.id)
{
identifiedPasskeyId.value = null;
}
}, 5000); // Clear highlight after 5 seconds
}
catch (error)
{
console.error('Identification error:', error);
identifiedPasskeyId.value = null;
if (error.name === 'NotAllowedError')
{
identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.';
}
else
{
identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.';
}
}
finally
{
identifyLoading.value = null; // Clear loading state
}
}
<template>
<q-page padding>
<div class="q-mb-md row justify-between items-center">
<div class="text-h4">
Passkey Management
</div>
<div>
<q-btn
label="Identify Passkey"
color="secondary"
class="q-mx-md q-mt-md"
@click="handleIdentify"
:loading="identifyLoading"
:disable="identifyLoading || !isLoggedIn"
outline
/>
<q-btn
label="Register New Passkey"
color="primary"
class="q-mx-md q-mt-md"
@click="handleRegister"
:loading="registerLoading"
:disable="registerLoading || !isLoggedIn"
outline
/>
</div>
</div>
<!-- Passkey List Section -->
<q-card-section>
<h5>Your Registered Passkeys</h5>
<q-list
bordered
separator
v-if="passkeys.length > 0 && !fetchLoading"
>
<q-item v-if="registerSuccessMessage || registerErrorMessage">
<div
v-if="registerSuccessMessage"
class="text-positive q-mt-md"
>
{{ registerSuccessMessage }}
</div>
<div
v-if="registerErrorMessage"
class="text-negative q-mt-md"
>
{{ registerErrorMessage }}
</div>
</q-item>
<q-item
v-for="passkey in passkeys"
:key="passkey.credentialID"
:class="{ 'bg-info text-h6': identifiedPasskeyId === passkey.credentialID }"
>
<q-item-section>
<q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label>
<q-item-label
caption
v-if="identifiedPasskeyId === passkey.credentialID"
>
Verified just now!
</q-item-label>
<!-- <q-item-label caption>Registered: {{ new Date(passkey.createdAt).toLocaleDateString() }}</q-item-label> -->
</q-item-section>
<q-item-section
side
class="row no-wrap items-center"
>
<!-- Delete Button -->
<q-btn
flat
dense
round
color="negative"
icon="delete"
@click="handleDelete(passkey.credentialID)"
:loading="deleteLoading === passkey.credentialID"
:disable="!!deleteLoading || !!identifyLoading"
/>
</q-item-section>
</q-item>
</q-list>
<div
v-else-if="fetchLoading"
class="q-mt-md"
>
Loading passkeys...
</div>
<div
v-else
class="q-mt-md"
>
You have no passkeys registered yet.
</div>
<div
v-if="fetchErrorMessage"
class="text-negative q-mt-md"
>
{{ fetchErrorMessage }}
</div>
<div
v-if="deleteSuccessMessage"
class="text-positive q-mt-md"
>
{{ deleteSuccessMessage }}
</div>
<div
v-if="deleteErrorMessage"
class="text-negative q-mt-md"
>
{{ deleteErrorMessage }}
</div>
<div
v-if="identifyErrorMessage"
class="text-negative q-mt-md"
>
{{ identifyErrorMessage }}
</div>
</q-card-section>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; // Import startAuthentication
import axios from 'boot/axios';
import { useAuthStore } from 'stores/auth';
const registerLoading = ref(false);
const registerErrorMessage = ref('');
const registerSuccessMessage = ref('');
const fetchLoading = ref(false);
const fetchErrorMessage = ref('');
const deleteLoading = ref(null);
const deleteErrorMessage = ref('');
const deleteSuccessMessage = ref('');
const identifyLoading = ref(null); // Store the ID of the passkey being identified
const identifyErrorMessage = ref('');
const identifiedPasskeyId = ref(null); // Store the ID of the successfully identified passkey
const authStore = useAuthStore();
const passkeys = ref([]); // To store the list of passkeys
// Computed properties to get state from the store
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = computed(() => authStore.user?.username);
// Fetch existing passkeys
async function fetchPasskeys()
{
if (!isLoggedIn.value) return;
fetchLoading.value = true;
fetchErrorMessage.value = '';
deleteSuccessMessage.value = ''; // Clear delete messages on refresh
deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; // Clear identify message
identifiedPasskeyId.value = null; // Clear identified key
try
{
const response = await axios.get('/api/auth/passkeys');
passkeys.value = response.data || [];
}
catch (error)
{
console.error('Error fetching passkeys:', error);
fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.';
passkeys.value = []; // Clear passkeys on error
}
finally
{
fetchLoading.value = false;
}
}
// Check auth status and fetch passkeys on component mount
onMounted(async() =>
{
let initialAuthError = '';
if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus();
if (authStore.error)
{
initialAuthError = `Authentication error: ${authStore.error}`;
}
}
if (!isLoggedIn.value)
{
// Use register error message ref for consistency if login is required first
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
}
else
{
fetchPasskeys(); // Fetch passkeys if logged in
}
});
async function handleRegister()
{
if (!isLoggedIn.value || !username.value)
{
registerErrorMessage.value = 'User not authenticated.';
return;
}
registerLoading.value = true;
registerErrorMessage.value = '';
registerSuccessMessage.value = '';
deleteSuccessMessage.value = ''; // Clear other messages
deleteErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
// 1. Get options from server
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
username: username.value, // Use username from store
});
const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/api/auth/verify-registration', {
registrationResponse: regResp,
});
if (verificationRes.data.verified)
{
registerSuccessMessage.value = 'New passkey registered successfully!';
fetchPasskeys(); // Refresh the list of passkeys
}
else
{
registerErrorMessage.value = 'Passkey verification failed.';
}
}
catch (error)
{
console.error('Registration error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
// Handle specific simplewebauthn errors
if (error.name === 'InvalidStateError')
{
registerErrorMessage.value = 'Authenticator may already be registered.';
}
else if (error.name === 'NotAllowedError')
{
registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.';
}
else if (error.response?.status === 409)
{
registerErrorMessage.value = 'This passkey seems to be registered already.';
}
else
{
registerErrorMessage.value = `Registration failed: ${message}`;
}
}
finally
{
registerLoading.value = false;
}
}
// Handle deleting a passkey
async function handleDelete(credentialID)
{
if (!credentialID) return;
// Optional: Add a confirmation dialog here
// if (!confirm('Are you sure you want to delete this passkey?')) {
// return;
// }
deleteLoading.value = credentialID; // Set loading state for the specific button
deleteErrorMessage.value = '';
deleteSuccessMessage.value = '';
registerSuccessMessage.value = ''; // Clear other messages
registerErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
await axios.delete(`/api/auth/passkeys/${credentialID}`);
deleteSuccessMessage.value = 'Passkey deleted successfully.';
fetchPasskeys(); // Refresh the list
}
catch (error)
{
console.error('Error deleting passkey:', error);
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
}
finally
{
deleteLoading.value = null; // Clear loading state
}
}
// Handle identifying a passkey
async function handleIdentify()
{
if (!isLoggedIn.value)
{
identifyErrorMessage.value = 'You must be logged in.';
return;
}
identifyLoading.value = true;
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; // Reset identified key
// Clear other messages
registerSuccessMessage.value = '';
registerErrorMessage.value = '';
deleteSuccessMessage.value = '';
deleteErrorMessage.value = '';
try
{
// 1. Get authentication options from the server
// We don't need to send username as the server should use the session
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {}); // Send empty body
const options = optionsRes.data;
// Optionally filter options to only allow the specific key if needed, but usually not necessary for identification
// options.allowCredentials = options.allowCredentials?.filter(cred => cred.id === credentialIDToIdentify);
// 2. Start authentication ceremony in the browser
const authResp = await startAuthentication(options);
// 3. If successful, the response contains the ID of the key used
identifiedPasskeyId.value = authResp.id;
console.log('Identified Passkey ID:', identifiedPasskeyId.value);
// Optional: Add a small delay before clearing the highlight
setTimeout(() =>
{
// Only clear if it's still the same identified key
if (identifiedPasskeyId.value === authResp.id)
{
identifiedPasskeyId.value = null;
}
}, 5000); // Clear highlight after 5 seconds
}
catch (error)
{
console.error('Identification error:', error);
identifiedPasskeyId.value = null;
if (error.name === 'NotAllowedError')
{
identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.';
}
else
{
identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.';
}
}
finally
{
identifyLoading.value = null; // Clear loading state
}
}
</script>

View file

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

View file

@ -1,202 +1,202 @@
<template>
<q-page padding>
<div
class="q-gutter-md"
style="max-width: 800px; margin: auto;"
>
<h5 class="q-mt-none q-mb-md">
Settings
</h5>
<q-card
flat
bordered
>
<q-card-section>
<div class="text-h6">
Mantis Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders.
</div>
<q-input
v-model="mantisPrompt"
type="textarea"
filled
autogrow
label="Mantis Prompt"
:loading="loadingPrompt"
:disable="savingPrompt"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Save Prompt"
color="primary"
@click="saveMantisPrompt"
:loading="savingPrompt"
:disable="!mantisPrompt || loadingPrompt"
/>
</q-card-actions>
</q-card>
<q-card
flat
bordered
>
<q-card-section>
<div class="text-h6">
Email Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array.
</div>
<q-input
v-model="emailPrompt"
type="textarea"
filled
autogrow
label="Email Prompt"
:loading="loadingEmailPrompt"
:disable="savingEmailPrompt"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Save Prompt"
color="primary"
@click="saveEmailPrompt"
:loading="savingEmailPrompt"
:disable="!emailPrompt || loadingEmailPrompt"
/>
</q-card-actions>
</q-card>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import axios from 'boot/axios';
const $q = useQuasar();
const mantisPrompt = ref('');
const loadingPrompt = ref(false);
const savingPrompt = ref(false);
const fetchMantisPrompt = async() =>
{
loadingPrompt.value = true;
try
{
const response = await axios.get('/api/settings/mantisPrompt');
mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
}
catch (error)
{
console.error('Error fetching Mantis prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to load Mantis prompt setting.',
icon: 'report_problem'
});
}
finally
{
loadingPrompt.value = false;
}
};
const saveMantisPrompt = async() =>
{
savingPrompt.value = true;
try
{
await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
$q.notify({
color: 'positive',
message: 'Mantis prompt updated successfully.',
icon: 'check_circle'
});
}
catch (error)
{
console.error('Error saving Mantis prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to save Mantis prompt setting.',
icon: 'report_problem'
});
}
finally
{
savingPrompt.value = false;
}
};
const emailPrompt = ref('');
const loadingEmailPrompt = ref(false);
const savingEmailPrompt = ref(false);
const fetchEmailPrompt = async() =>
{
loadingEmailPrompt.value = true;
try
{
const response = await axios.get('/api/settings/emailPrompt');
emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
}
catch (error)
{
console.error('Error fetching Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to load Email prompt setting.',
icon: 'report_problem'
});
}
finally
{
loadingEmailPrompt.value = false;
}
};
const saveEmailPrompt = async() =>
{
savingEmailPrompt.value = true;
try
{
await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
$q.notify({
color: 'positive',
message: 'Email prompt updated successfully.',
icon: 'check_circle'
});
}
catch (error)
{
console.error('Error saving Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to save Email prompt setting.',
icon: 'report_problem'
});
}
finally
{
savingEmailPrompt.value = false;
}
};
onMounted(() =>
{
fetchMantisPrompt();
fetchEmailPrompt();
});
</script>
<style scoped>
/* Add any specific styles if needed */
</style>
<template>
<q-page padding>
<div
class="q-gutter-md"
style="max-width: 800px; margin: auto;"
>
<h5 class="q-mt-none q-mb-md">
Settings
</h5>
<q-card
flat
bordered
>
<q-card-section>
<div class="text-h6">
Mantis Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders.
</div>
<q-input
v-model="mantisPrompt"
type="textarea"
filled
autogrow
label="Mantis Prompt"
:loading="loadingPrompt"
:disable="savingPrompt"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Save Prompt"
color="primary"
@click="saveMantisPrompt"
:loading="savingPrompt"
:disable="!mantisPrompt || loadingPrompt"
/>
</q-card-actions>
</q-card>
<q-card
flat
bordered
>
<q-card-section>
<div class="text-h6">
Email Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array.
</div>
<q-input
v-model="emailPrompt"
type="textarea"
filled
autogrow
label="Email Prompt"
:loading="loadingEmailPrompt"
:disable="savingEmailPrompt"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Save Prompt"
color="primary"
@click="saveEmailPrompt"
:loading="savingEmailPrompt"
:disable="!emailPrompt || loadingEmailPrompt"
/>
</q-card-actions>
</q-card>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import axios from 'boot/axios';
const $q = useQuasar();
const mantisPrompt = ref('');
const loadingPrompt = ref(false);
const savingPrompt = ref(false);
const fetchMantisPrompt = async() =>
{
loadingPrompt.value = true;
try
{
const response = await axios.get('/api/settings/mantisPrompt');
mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
}
catch (error)
{
console.error('Error fetching Mantis prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to load Mantis prompt setting.',
icon: 'report_problem'
});
}
finally
{
loadingPrompt.value = false;
}
};
const saveMantisPrompt = async() =>
{
savingPrompt.value = true;
try
{
await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
$q.notify({
color: 'positive',
message: 'Mantis prompt updated successfully.',
icon: 'check_circle'
});
}
catch (error)
{
console.error('Error saving Mantis prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to save Mantis prompt setting.',
icon: 'report_problem'
});
}
finally
{
savingPrompt.value = false;
}
};
const emailPrompt = ref('');
const loadingEmailPrompt = ref(false);
const savingEmailPrompt = ref(false);
const fetchEmailPrompt = async() =>
{
loadingEmailPrompt.value = true;
try
{
const response = await axios.get('/api/settings/emailPrompt');
emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
}
catch (error)
{
console.error('Error fetching Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to load Email prompt setting.',
icon: 'report_problem'
});
}
finally
{
loadingEmailPrompt.value = false;
}
};
const saveEmailPrompt = async() =>
{
savingEmailPrompt.value = true;
try
{
await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
$q.notify({
color: 'positive',
message: 'Email prompt updated successfully.',
icon: 'check_circle'
});
}
catch (error)
{
console.error('Error saving Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to save Email prompt setting.',
icon: 'report_problem'
});
}
finally
{
savingEmailPrompt.value = false;
}
};
onMounted(() =>
{
fetchMantisPrompt();
fetchEmailPrompt();
});
</script>
<style scoped>
/* Add any specific styles if needed */
</style>

View file

@ -12,7 +12,7 @@ import { useAuthStore } from 'stores/auth'; // Import the auth store
* with the Router instance.
*/
export default defineRouter(function({ store /* { store, ssrContext } */ })
export default defineRouter(function({ store /* { store, ssrContext } */ })
{
const createHistory = process.env.SERVER
? createMemoryHistory
@ -29,19 +29,19 @@ export default defineRouter(function({ store /* { store, ssrContext } */ })
});
// Navigation Guard using Pinia store
Router.beforeEach(async(to, from, next) =>
Router.beforeEach(async(to, from, next) =>
{
const authStore = useAuthStore(store); // Get store instance
// Ensure auth status is checked, especially on first load or refresh
// This check might be better placed in App.vue or a boot file
if (!authStore.user && !authStore.loading)
if (!authStore.user && !authStore.loading)
{ // Check only if user is not loaded and not already loading
try
try
{
await authStore.checkAuthStatus();
}
catch (e)
catch (e)
{
// console.error('Initial auth check failed', e);
// 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 isAuthenticated = authStore.isAuthenticated; // Get status from store
if (requiresAuth && !isAuthenticated)
if (requiresAuth && !isAuthenticated)
{
next('/login');
}
else if (isPublicPage && isAuthenticated)
else if (isPublicPage && isAuthenticated)
{
next('/');
}
else
else
{
next();
}

View file

@ -9,40 +9,40 @@ export const useAuthStore = defineStore('auth', {
error: null, // Optional: track errors
}),
actions: {
async checkAuthStatus()
async checkAuthStatus()
{
this.loading = true;
this.error = null;
try
try
{
const res = await axios.get('/api/auth/status', {
withCredentials: true, // Ensure cookies are sent with the request
});
if (res.data.status === 'authenticated')
if (res.data.status === 'authenticated')
{
this.isAuthenticated = true;
this.user = res.data.user;
}
else
else
{
this.isAuthenticated = false;
this.user = null;
}
}
catch (error)
catch (error)
{
// console.error('Failed to check authentication status:', error);
this.error = 'Could not verify login status.';
this.isAuthenticated = false;
this.user = null;
}
finally
finally
{
this.loading = false;
}
},
// Action to manually set user as logged out (e.g., after logout)
logout()
logout()
{
this.isAuthenticated = false;
this.user = null;

View file

@ -1,256 +1,256 @@
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue'; // Import watch
import axios from 'boot/axios';
export const useChatStore = defineStore('chat', () =>
{
const isVisible = ref(false);
const currentThreadId = ref(null);
const messages = ref([]); // Array of { sender: 'user' | 'bot', content: string, createdAt?: Date, loading?: boolean }
const isLoading = ref(false);
const error = ref(null);
const pollingIntervalId = ref(null); // To store the interval ID
// --- Getters ---
const chatMessages = computed(() => messages.value);
const isChatVisible = computed(() => isVisible.value);
const activeThreadId = computed(() => currentThreadId.value);
// --- Actions ---
// New action to create a thread if it doesn't exist
async function createThreadIfNotExists()
{
if (currentThreadId.value) return; // Already have a thread
isLoading.value = true;
error.value = null;
try
{
// Call the endpoint without content to just create the thread
const response = await axios.post('/api/chat/threads', {});
currentThreadId.value = response.data.threadId;
messages.value = []; // Start with an empty message list for the new thread
console.log('Created new chat thread:', currentThreadId.value);
// Start polling now that we have a thread ID
startPolling();
}
catch (err)
{
console.error('Error creating chat thread:', err);
error.value = 'Failed to start chat.';
// Don't set isVisible to false, let the user see the error
}
finally
{
isLoading.value = false;
}
}
function toggleChat()
{
isVisible.value = !isVisible.value;
if (isVisible.value)
{
if (!currentThreadId.value)
{
// If opening and no thread exists, create one
createThreadIfNotExists();
}
else
{
// If opening and thread exists, fetch messages if empty and start polling
if (messages.value.length === 0)
{
fetchMessages();
}
startPolling();
}
}
else
{
// If closing, stop polling
stopPolling();
}
}
async function fetchMessages()
{
if (!currentThreadId.value)
{
console.log('No active thread to fetch messages for.');
// Don't try to fetch if no thread ID yet. createThreadIfNotExists handles the initial state.
return;
}
// Avoid setting isLoading if polling, maybe use a different flag? For now, keep it simple.
// isLoading.value = true; // Might cause flickering during polling
error.value = null; // Clear previous errors on fetch attempt
try
{
const response = await axios.get(`/api/chat/threads/${currentThreadId.value}/messages`);
const newMessages = response.data.map(msg => ({
sender: msg.sender,
content: msg.content,
createdAt: new Date(msg.createdAt),
loading: msg.content === 'Loading...'
})).sort((a, b) => a.createdAt - b.createdAt);
// Only update if messages have actually changed to prevent unnecessary re-renders
if (JSON.stringify(messages.value) !== JSON.stringify(newMessages))
{
messages.value = newMessages;
}
}
catch (err)
{
console.error('Error fetching messages:', err);
error.value = 'Failed to load messages.';
// Don't clear messages on polling error, keep the last known state
// messages.value = [];
stopPolling(); // Stop polling if there's an error fetching
}
finally
{
// isLoading.value = false;
}
}
// Function to start polling
function startPolling()
{
if (pollingIntervalId.value) return; // Already polling
if (!currentThreadId.value) return; // No thread to poll for
console.log('Starting chat polling for thread:', currentThreadId.value);
pollingIntervalId.value = setInterval(fetchMessages, 5000); // Poll every 5 seconds
}
// Function to stop polling
function stopPolling()
{
if (pollingIntervalId.value)
{
console.log('Stopping chat polling.');
clearInterval(pollingIntervalId.value);
pollingIntervalId.value = null;
}
}
async function sendMessage(content)
{
if (!content.trim()) return;
if (!currentThreadId.value)
{
error.value = 'Cannot send message: No active chat thread.';
console.error('Attempted to send message without a thread ID.');
return; // Should not happen if UI waits for thread creation
}
const userMessage = {
sender: 'user',
content: content.trim(),
createdAt: new Date(),
};
messages.value.push(userMessage);
const loadingMessage = { sender: 'bot', content: '...', loading: true, createdAt: new Date(Date.now() + 1) }; // Ensure unique key/time
messages.value.push(loadingMessage);
// Stop polling temporarily while sending a message to avoid conflicts
stopPolling();
isLoading.value = true; // Indicate activity
error.value = null;
try
{
const payload = { content: userMessage.content };
// Always post to the existing thread once it's created
const response = await axios.post(`/api/chat/threads/${currentThreadId.value}/messages`, payload);
// Remove loading indicator
messages.value = messages.value.filter(m => !m.loading);
// 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.
// Immediately fetch messages after sending to get the updated list
await fetchMessages();
}
catch (err)
{
console.error('Error sending message:', err);
error.value = 'Failed to send message.';
// Remove loading indicator on error
messages.value = messages.value.filter(m => !m.loading);
// Optionally add an error message to the chat
// Ensure the object is correctly formatted
messages.value.push({ sender: 'bot', content: "Sorry, I couldn't send that message.", createdAt: new Date() });
}
finally
{
isLoading.value = false;
// Restart polling after sending attempt is complete
startPolling();
}
}
// Call this when the user logs out or the app closes if you want to clear state
function resetChat()
{
stopPolling(); // Ensure polling stops on reset
isVisible.value = false;
currentThreadId.value = null;
messages.value = [];
isLoading.value = false;
error.value = null;
}
// Watch for visibility changes to manage polling (alternative to putting logic in toggleChat)
// watch(isVisible, (newValue) => {
// if (newValue && currentThreadId.value) {
// startPolling();
// } else {
// stopPolling();
// }
// });
// Watch for thread ID changes (e.g., after creation)
// watch(currentThreadId, (newId) => {
// if (newId && isVisible.value) {
// messages.value = []; // Clear old messages if any
// fetchMessages(); // Fetch messages for the new thread
// startPolling(); // Start polling for the new thread
// } else {
// stopPolling(); // Stop polling if thread ID becomes null
// }
// });
return {
// State refs
isVisible,
currentThreadId,
messages,
isLoading,
error,
// Computed getters
chatMessages,
isChatVisible,
activeThreadId,
// Actions
toggleChat,
sendMessage,
fetchMessages, // Expose if needed externally
resetChat,
// Expose polling control if needed externally, though typically managed internally
// startPolling,
// stopPolling,
};
});
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue'; // Import watch
import axios from 'boot/axios';
export const useChatStore = defineStore('chat', () =>
{
const isVisible = ref(false);
const currentThreadId = ref(null);
const messages = ref([]); // Array of { sender: 'user' | 'bot', content: string, createdAt?: Date, loading?: boolean }
const isLoading = ref(false);
const error = ref(null);
const pollingIntervalId = ref(null); // To store the interval ID
// --- Getters ---
const chatMessages = computed(() => messages.value);
const isChatVisible = computed(() => isVisible.value);
const activeThreadId = computed(() => currentThreadId.value);
// --- Actions ---
// New action to create a thread if it doesn't exist
async function createThreadIfNotExists()
{
if (currentThreadId.value) return; // Already have a thread
isLoading.value = true;
error.value = null;
try
{
// Call the endpoint without content to just create the thread
const response = await axios.post('/api/chat/threads', {});
currentThreadId.value = response.data.threadId;
messages.value = []; // Start with an empty message list for the new thread
console.log('Created new chat thread:', currentThreadId.value);
// Start polling now that we have a thread ID
startPolling();
}
catch (err)
{
console.error('Error creating chat thread:', err);
error.value = 'Failed to start chat.';
// Don't set isVisible to false, let the user see the error
}
finally
{
isLoading.value = false;
}
}
function toggleChat()
{
isVisible.value = !isVisible.value;
if (isVisible.value)
{
if (!currentThreadId.value)
{
// If opening and no thread exists, create one
createThreadIfNotExists();
}
else
{
// If opening and thread exists, fetch messages if empty and start polling
if (messages.value.length === 0)
{
fetchMessages();
}
startPolling();
}
}
else
{
// If closing, stop polling
stopPolling();
}
}
async function fetchMessages()
{
if (!currentThreadId.value)
{
console.log('No active thread to fetch messages for.');
// Don't try to fetch if no thread ID yet. createThreadIfNotExists handles the initial state.
return;
}
// Avoid setting isLoading if polling, maybe use a different flag? For now, keep it simple.
// isLoading.value = true; // Might cause flickering during polling
error.value = null; // Clear previous errors on fetch attempt
try
{
const response = await axios.get(`/api/chat/threads/${currentThreadId.value}/messages`);
const newMessages = response.data.map(msg => ({
sender: msg.sender,
content: msg.content,
createdAt: new Date(msg.createdAt),
loading: msg.content === 'Loading...'
})).sort((a, b) => a.createdAt - b.createdAt);
// Only update if messages have actually changed to prevent unnecessary re-renders
if (JSON.stringify(messages.value) !== JSON.stringify(newMessages))
{
messages.value = newMessages;
}
}
catch (err)
{
console.error('Error fetching messages:', err);
error.value = 'Failed to load messages.';
// Don't clear messages on polling error, keep the last known state
// messages.value = [];
stopPolling(); // Stop polling if there's an error fetching
}
finally
{
// isLoading.value = false;
}
}
// Function to start polling
function startPolling()
{
if (pollingIntervalId.value) return; // Already polling
if (!currentThreadId.value) return; // No thread to poll for
console.log('Starting chat polling for thread:', currentThreadId.value);
pollingIntervalId.value = setInterval(fetchMessages, 5000); // Poll every 5 seconds
}
// Function to stop polling
function stopPolling()
{
if (pollingIntervalId.value)
{
console.log('Stopping chat polling.');
clearInterval(pollingIntervalId.value);
pollingIntervalId.value = null;
}
}
async function sendMessage(content)
{
if (!content.trim()) return;
if (!currentThreadId.value)
{
error.value = 'Cannot send message: No active chat thread.';
console.error('Attempted to send message without a thread ID.');
return; // Should not happen if UI waits for thread creation
}
const userMessage = {
sender: 'user',
content: content.trim(),
createdAt: new Date(),
};
messages.value.push(userMessage);
const loadingMessage = { sender: 'bot', content: '...', loading: true, createdAt: new Date(Date.now() + 1) }; // Ensure unique key/time
messages.value.push(loadingMessage);
// Stop polling temporarily while sending a message to avoid conflicts
stopPolling();
isLoading.value = true; // Indicate activity
error.value = null;
try
{
const payload = { content: userMessage.content };
// Always post to the existing thread once it's created
const response = await axios.post(`/api/chat/threads/${currentThreadId.value}/messages`, payload);
// Remove loading indicator
messages.value = messages.value.filter(m => !m.loading);
// 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.
// Immediately fetch messages after sending to get the updated list
await fetchMessages();
}
catch (err)
{
console.error('Error sending message:', err);
error.value = 'Failed to send message.';
// Remove loading indicator on error
messages.value = messages.value.filter(m => !m.loading);
// Optionally add an error message to the chat
// Ensure the object is correctly formatted
messages.value.push({ sender: 'bot', content: "Sorry, I couldn't send that message.", createdAt: new Date() });
}
finally
{
isLoading.value = false;
// Restart polling after sending attempt is complete
startPolling();
}
}
// Call this when the user logs out or the app closes if you want to clear state
function resetChat()
{
stopPolling(); // Ensure polling stops on reset
isVisible.value = false;
currentThreadId.value = null;
messages.value = [];
isLoading.value = false;
error.value = null;
}
// Watch for visibility changes to manage polling (alternative to putting logic in toggleChat)
// watch(isVisible, (newValue) => {
// if (newValue && currentThreadId.value) {
// startPolling();
// } else {
// stopPolling();
// }
// });
// Watch for thread ID changes (e.g., after creation)
// watch(currentThreadId, (newId) => {
// if (newId && isVisible.value) {
// messages.value = []; // Clear old messages if any
// fetchMessages(); // Fetch messages for the new thread
// startPolling(); // Start polling for the new thread
// } else {
// stopPolling(); // Stop polling if thread ID becomes null
// }
// });
return {
// State refs
isVisible,
currentThreadId,
messages,
isLoading,
error,
// Computed getters
chatMessages,
isChatVisible,
activeThreadId,
// Actions
toggleChat,
sendMessage,
fetchMessages, // Expose if needed externally
resetChat,
// Expose polling control if needed externally, though typically managed internally
// startPolling,
// stopPolling,
};
});

View file

@ -1,21 +1,21 @@
import { defineStore } from '#q-app/wrappers';
import { createPinia } from 'pinia';
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default defineStore((/* { ssrContext } */) =>
{
const pinia = createPinia();
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia;
import { defineStore } from '#q-app/wrappers';
import { createPinia } from 'pinia';
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default defineStore((/* { ssrContext } */) =>
{
const pinia = createPinia();
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia;
});