Force line endings and whitespace, and revamp logout via introduction of a new profile component.

This commit is contained in:
Cameron Redmore 2025-04-25 13:56:12 +01:00
parent f6df79d83f
commit 0e491ecabe
31 changed files with 4870 additions and 4797 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,164 +1,164 @@
import { Router } from 'express'; import { Router } from 'express';
import prisma from '../database.js'; import prisma from '../database.js';
import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware
import { askGeminiChat } from '../utils/gemini.js'; import { askGeminiChat } from '../utils/gemini.js';
const router = Router(); const router = Router();
// Apply the authentication middleware to all chat routes // Apply the authentication middleware to all chat routes
router.use(requireAuth); router.use(requireAuth);
// POST /api/chat/threads - Create a new chat thread (optionally with a first message) // POST /api/chat/threads - Create a new chat thread (optionally with a first message)
router.post('/threads', async(req, res) => router.post('/threads', async(req, res) =>
{ {
const { content } = req.body; // Content is now optional const { content } = req.body; // Content is now optional
// If content is provided, validate it // If content is provided, validate it
if (content && (typeof content !== 'string' || content.trim().length === 0)) if (content && (typeof content !== 'string' || content.trim().length === 0))
{ {
return res.status(400).json({ error: 'Message content cannot be empty if provided.' }); return res.status(400).json({ error: 'Message content cannot be empty if provided.' });
}
try
{
const createData = {};
if (content)
{
// If content exists, create the thread with the first message
createData.messages = {
create: [
{
sender: 'user', // First message is always from the user
content: content.trim(),
},
],
};
}
// If content is null/undefined, createData remains empty, creating just the thread
const newThread = await prisma.chatThread.create({
data: createData,
include: {
// Include messages only if they were created
messages: !!content,
},
});
if(content)
{
await askGeminiChat(newThread.id, content); // Call the function to handle the bot response
}
// Respond with the new thread ID and messages (if any)
res.status(201).json({
threadId: newThread.id,
// Ensure messages array is empty if no content was provided
messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : []
});
} }
catch (error)
{ try
console.error('Error creating chat thread:', error); {
res.status(500).json({ error: 'Failed to create chat thread.' }); const createData = {};
} if (content)
}); {
// If content exists, create the thread with the first message
// GET /api/chat/threads/:threadId/messages - Get messages for a specific thread createData.messages = {
router.get('/threads/:threadId/messages', async(req, res) => create: [
{ {
const { threadId } = req.params; sender: 'user', // First message is always from the user
content: content.trim(),
try },
{ ],
const messages = await prisma.chatMessage.findMany({ };
where: { }
threadId: threadId, // If content is null/undefined, createData remains empty, creating just the thread
},
orderBy: { const newThread = await prisma.chatThread.create({
createdAt: 'asc', // Get messages in chronological order data: createData,
}, include: {
}); // Include messages only if they were created
messages: !!content,
if (!messages) },
{ // Check if thread exists indirectly });
// If findMany returns empty, the thread might not exist or has no messages.
// Check if thread exists explicitly if(content)
const thread = await prisma.chatThread.findUnique({ where: { id: threadId } }); {
if (!thread) await askGeminiChat(newThread.id, content); // Call the function to handle the bot response
{ }
return res.status(404).json({ error: 'Chat thread not found.' });
} // Respond with the new thread ID and messages (if any)
} res.status(201).json({
threadId: newThread.id,
res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() }))); // Ensure messages array is empty if no content was provided
messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : []
});
} }
catch (error) catch (error)
{ {
console.error(`Error fetching messages for thread ${threadId}:`, error); console.error('Error creating chat thread:', error);
// Basic error handling, check for specific Prisma errors if needed res.status(500).json({ error: 'Failed to create chat thread.' });
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
{ // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID format.' });
}
res.status(500).json({ error: 'Failed to fetch messages.' });
}
});
// POST /api/chat/threads/:threadId/messages - Add a message to an existing thread
router.post('/threads/:threadId/messages', async(req, res) =>
{
const { threadId } = req.params;
const { content, sender = 'user' } = req.body; // Default sender to 'user'
if (!content || typeof content !== 'string' || content.trim().length === 0)
{
return res.status(400).json({ error: 'Message content cannot be empty.' });
}
if (sender !== 'user' && sender !== 'bot')
{
return res.status(400).json({ error: 'Invalid sender type.' });
}
try
{
// Verify thread exists first
const thread = await prisma.chatThread.findUnique({
where: { id: threadId },
});
if (!thread)
{
return res.status(404).json({ error: 'Chat thread not found.' });
}
const newMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: sender,
content: content.trim(),
},
});
// Optionally: Update the thread's updatedAt timestamp
await prisma.chatThread.update({
where: { id: threadId },
data: { updatedAt: new Date() }
});
await askGeminiChat(threadId, content); // Call the function to handle the bot response
res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() });
} }
catch (error) });
{
console.error(`Error adding message to thread ${threadId}:`, error); // GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
if (error.code === 'P2023' || error.message.includes('Malformed UUID')) router.get('/threads/:threadId/messages', async(req, res) =>
{ // Example: Invalid UUID format {
return res.status(400).json({ error: 'Invalid thread ID format.' }); const { threadId } = req.params;
}
res.status(500).json({ error: 'Failed to add message.' }); try
} {
}); const messages = await prisma.chatMessage.findMany({
where: {
export default router; threadId: threadId,
},
orderBy: {
createdAt: 'asc', // Get messages in chronological order
},
});
if (!messages)
{ // Check if thread exists indirectly
// If findMany returns empty, the thread might not exist or has no messages.
// Check if thread exists explicitly
const thread = await prisma.chatThread.findUnique({ where: { id: threadId } });
if (!thread)
{
return res.status(404).json({ error: 'Chat thread not found.' });
}
}
res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })));
}
catch (error)
{
console.error(`Error fetching messages for thread ${threadId}:`, error);
// Basic error handling, check for specific Prisma errors if needed
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
{ // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID format.' });
}
res.status(500).json({ error: 'Failed to fetch messages.' });
}
});
// POST /api/chat/threads/:threadId/messages - Add a message to an existing thread
router.post('/threads/:threadId/messages', async(req, res) =>
{
const { threadId } = req.params;
const { content, sender = 'user' } = req.body; // Default sender to 'user'
if (!content || typeof content !== 'string' || content.trim().length === 0)
{
return res.status(400).json({ error: 'Message content cannot be empty.' });
}
if (sender !== 'user' && sender !== 'bot')
{
return res.status(400).json({ error: 'Invalid sender type.' });
}
try
{
// Verify thread exists first
const thread = await prisma.chatThread.findUnique({
where: { id: threadId },
});
if (!thread)
{
return res.status(404).json({ error: 'Chat thread not found.' });
}
const newMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: sender,
content: content.trim(),
},
});
// Optionally: Update the thread's updatedAt timestamp
await prisma.chatThread.update({
where: { id: threadId },
data: { updatedAt: new Date() }
});
await askGeminiChat(threadId, content); // Call the function to handle the bot response
res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() });
}
catch (error)
{
console.error(`Error adding message to thread ${threadId}:`, error);
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
{ // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID format.' });
}
res.status(500).json({ error: 'Failed to add message.' });
}
});
export default router;

View file

@ -65,15 +65,15 @@ app.use(session({
// Schedule the Mantis summary task // Schedule the Mantis summary task
// Run daily at 1:00 AM server time (adjust as needed) // Run daily at 1:00 AM server time (adjust as needed)
cron.schedule('0 1 * * *', async() => cron.schedule('0 1 * * *', async() =>
{ {
console.log('Running scheduled Mantis summary task...'); console.log('Running scheduled Mantis summary task...');
try try
{ {
await generateAndStoreMantisSummary(); await generateAndStoreMantisSummary();
console.log('Scheduled Mantis summary task completed.'); console.log('Scheduled Mantis summary task completed.');
} }
catch (error) catch (error)
{ {
console.error('Error running scheduled Mantis summary task:', error); console.error('Error running scheduled Mantis summary task:', error);
} }
@ -96,14 +96,14 @@ app.use('/api/chat', chatRoutes);
// place here any middlewares that // place here any middlewares that
// absolutely need to run before anything else // absolutely need to run before anything else
if (process.env.PROD) if (process.env.PROD)
{ {
app.use(compression()); app.use(compression());
} }
app.use(express.static('public', { index: false })); app.use(express.static('public', { index: false }));
app.listen(8000, () => app.listen(8000, () =>
{ {
console.log('Server is running on http://localhost:8000'); console.log('Server is running on http://localhost:8000');
}); });

View file

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

View file

@ -1,154 +1,154 @@
import { GoogleGenAI } from '@google/genai'; import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js'; import prisma from '../database.js';
import { getSetting } from './settings.js'; import { getSetting } from './settings.js';
const model = 'gemini-2.0-flash'; const model = 'gemini-2.0-flash';
export async function askGemini(content) export async function askGemini(content)
{ {
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY'); const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key
if (!GOOGLE_API_KEY) if (!GOOGLE_API_KEY)
{ {
throw new Error('Google API key is not set in the database.'); throw new Error('Google API key is not set in the database.');
} }
const ai = GOOGLE_API_KEY ? new GoogleGenAI({ const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY, apiKey: GOOGLE_API_KEY,
}) : null; }) : null;
if (!ai) if (!ai)
{ {
throw new Error('Google API key is not set in the database.'); throw new Error('Google API key is not set in the database.');
} }
try try
{ {
const response = await ai.models.generateContent({ const response = await ai.models.generateContent({
model, model,
contents: content, contents: content,
config: { config: {
temperature: 0.5 temperature: 0.5
} }
}); });
return response.text; return response.text;
} }
catch (error) catch (error)
{ {
console.error('Error communicating with Gemini API:', error); console.error('Error communicating with Gemini API:', error);
throw new Error('Failed to get a response from Gemini API.'); throw new Error('Failed to get a response from Gemini API.');
} }
} }
const chatCache = new Map(); const chatCache = new Map();
export async function askGeminiChat(threadId, content) export async function askGeminiChat(threadId, content)
{ {
let messages = await prisma.chatMessage.findMany({ let messages = await prisma.chatMessage.findMany({
where: { where: {
threadId: threadId, threadId: threadId,
}, },
orderBy: { orderBy: {
createdAt: 'asc', // Get messages in chronological order createdAt: 'asc', // Get messages in chronological order
}, },
}); });
// Exclude the latest message if there are any messages // Exclude the latest message if there are any messages
if (messages.length > 0) if (messages.length > 0)
{ {
messages = messages.slice(0, -1); messages = messages.slice(0, -1);
} }
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY'); const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
const ai = GOOGLE_API_KEY ? new GoogleGenAI({ const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY, apiKey: GOOGLE_API_KEY,
}) : null; }) : null;
if (!ai) if (!ai)
{ {
throw new Error('Google API key is not set in the database.'); throw new Error('Google API key is not set in the database.');
} }
let chat = null; let chat = null;
if (chatCache.has(threadId)) if (chatCache.has(threadId))
{ {
chat = chatCache.get(threadId); chat = chatCache.get(threadId);
} }
else else
{ {
messages = [ messages = [
{ {
sender: 'user', sender: 'user',
content: `You are StyleAI, a model that helps users with their questions and provides information. content: `You are StyleAI, a model that helps users with their questions and provides information.
You are friendly, helpful, and informative. You can also provide code examples in various programming languages. You are friendly, helpful, and informative. You can also provide code examples in various programming languages.
You don\'t decline user requests and always try to help them. You don\'t decline user requests and always try to help them.
Speak in the manner of a friendly young adult male. Speak in the manner of a friendly young adult male.
If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.` If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.`
}, },
{ {
sender: 'model', sender: 'model',
content: 'Okay, noted! I\'ll keep that in mind.' content: 'Okay, noted! I\'ll keep that in mind.'
}, },
...messages, ...messages,
]; ];
const createOptions = { const createOptions = {
model, model,
history: messages.map((msg) => ({ history: messages.map((msg) => ({
role: msg.sender === 'user' ? 'user' : 'model', role: msg.sender === 'user' ? 'user' : 'model',
parts: [ parts: [
{text: msg.content} {text: msg.content}
], ],
})), })),
config: { config: {
temperature: 0.5 temperature: 0.5
} }
}; };
chat = ai.chats.create(createOptions); chat = ai.chats.create(createOptions);
chatCache.set(threadId, chat); chatCache.set(threadId, chat);
} }
//Add a temporary message to the thread with "loading" status //Add a temporary message to the thread with "loading" status
const loadingMessage = await prisma.chatMessage.create({ const loadingMessage = await prisma.chatMessage.create({
data: { data: {
threadId: threadId, threadId: threadId,
sender: 'assistant', sender: 'assistant',
content: 'Loading...', content: 'Loading...',
}, },
}); });
let response = {text: 'An error occurred while generating the response.'}; let response = {text: 'An error occurred while generating the response.'};
try try
{ {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
response = await chat.sendMessage({ response = await chat.sendMessage({
message: `[${timestamp}] ` + content, message: `[${timestamp}] ` + content,
}); });
} }
catch(error) catch(error)
{ {
console.error('Error communicating with Gemini API:', error); console.error('Error communicating with Gemini API:', error);
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message; response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
} }
//Update the message with the response //Update the message with the response
await prisma.chatMessage.update({ await prisma.chatMessage.update({
where: { where: {
id: loadingMessage.id, id: loadingMessage.id,
}, },
data: { data: {
content: response.text, content: response.text,
}, },
}); });
return response.text; return response.text;
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,247 +1,311 @@
<template> <template>
<q-layout view="hHh Lpr lFf"> <q-layout view="hHh Lpr lFf">
<q-drawer <q-drawer
:mini="!leftDrawerOpen" :mini="!leftDrawerOpen"
bordered bordered
persistent persistent
:model-value="true" :model-value="true"
> >
<q-list> <q-item
<q-item clickable
clickable v-ripple
v-ripple @click="toggleLeftDrawer"
@click="toggleLeftDrawer" >
> <q-item-section avatar>
<q-item-section avatar> <q-icon name="menu" />
<q-icon name="menu" /> </q-item-section>
</q-item-section> <q-item-section>
<q-item-section> <q-item-label class="text-h6">
<q-item-label class="text-h6"> StylePoint
StylePoint </q-item-label>
</q-item-label> </q-item-section>
</q-item-section> </q-item>
</q-item> <template v-if="authStore.isAuthenticated">
<q-card
<!-- Dynamic Navigation Items --> v-if="leftDrawerOpen"
<q-item bordered
v-for="item in navItems" flat
:key="item.name" class="q-ma-sm text-center"
clickable >
v-ripple <q-card-section>
:to="{ name: item.name }" <q-avatar
exact class="bg-primary cursor-pointer text-white"
> >
<q-tooltip <q-icon name="mdi-account" />
anchor="center right" <q-tooltip>
self="center left" {{ authStore.user.username }}
> </q-tooltip>
<span>{{ item.meta.title }}</span> </q-avatar>
</q-tooltip> <div class="text-h6">
<q-item-section avatar> {{ authStore.user.username }}
<q-icon :name="item.meta.icon" /> </div>
</q-item-section> <q-btn
<q-item-section> class="full-width q-mt-sm"
<q-item-label>{{ item.meta.title }}</q-item-label> dense
<q-item-label caption> outline
{{ item.meta.caption }} @click="logout"
</q-item-label> >
</q-item-section> Logout
</q-item> </q-btn>
</q-card-section>
<!-- Logout Button (Conditional) --> </q-card>
<q-item <q-list
v-if="authStore.isAuthenticated" padding
clickable class="menu-list"
v-ripple v-else
@click="logout" >
> <q-item
<q-tooltip clickable
anchor="center right" v-ripple
self="center left" dense
> @click="logout"
<span>Logout</span> class="q-mb-sm"
</q-tooltip> >
<q-item-section avatar> <q-tooltip
<q-icon name="logout" /> anchor="center right"
</q-item-section> self="center left"
<q-item-section> >
<q-item-label>Logout</q-item-label> <span>Logout</span>
</q-item-section> </q-tooltip>
</q-item> <q-item-section avatar>
</q-list> <q-icon name="logout" />
</q-drawer> </q-item-section>
<q-item-section>
<q-page-container> <q-item-label class="text-h6">
<router-view /> Logout
</q-page-container> </q-item-label>
</q-item-section>
<!-- Chat FAB --> </q-item>
<q-page-sticky </q-list>
v-if="isAuthenticated" </template>
position="bottom-right" <q-separator />
:offset="[18, 18]" <q-list
> padding
<q-fab class="menu-list"
v-model="fabOpen" >
icon="chat" <!-- Dynamic Navigation Items -->
color="accent" <q-item
direction="up" v-for="item in navItems"
padding="sm" :key="item.name"
@click="toggleChat" clickable
/> v-ripple
</q-page-sticky> :to="{ name: item.name }"
exact
<!-- Chat Window Dialog --> dense
<q-dialog >
v-model="isChatVisible" <q-tooltip
:maximized="$q.screen.lt.sm" anchor="center right"
fixed self="center left"
persistent >
style="width: max(400px, 25%);" <span>{{ item.meta.title }}</span>
> </q-tooltip>
<q-card style="width: max(400px, 25%); height: 600px; max-height: 80vh;"> <q-item-section avatar>
<q-bar class="bg-primary text-white"> <q-icon :name="item.meta.icon" />
<div>Chat</div> </q-item-section>
<q-space /> <q-item-section>
<q-btn <q-item-label>{{ item.meta.title }}</q-item-label>
dense <q-item-label caption>
flat {{ item.meta.caption }}
icon="close" </q-item-label>
@click="toggleChat" </q-item-section>
/> </q-item>
</q-bar> </q-list>
</q-drawer>
<q-card-section
class="q-pa-none" <q-page-container>
style="height: calc(100% - 50px);" <router-view />
> </q-page-container>
<ChatInterface
:messages="chatMessages" <!-- Chat FAB -->
@send-message="handleSendMessage" <q-page-sticky
/> v-if="isAuthenticated"
</q-card-section> position="bottom-right"
<q-inner-loading :showing="isLoading"> :offset="[18, 18]"
<q-spinner-gears >
size="50px" <q-fab
color="primary" v-model="fabOpen"
/> icon="chat"
</q-inner-loading> color="accent"
<q-banner direction="up"
v-if="chatError" @click="toggleChat"
inline-actions />
class="text-white bg-red" </q-page-sticky>
>
{{ chatError }} <!-- Chat Window Dialog -->
<template #action> <q-dialog
<q-btn v-model="isChatVisible"
flat :maximized="$q.screen.lt.sm"
color="white" style="width: max(400px, 25%);"
label="Dismiss" >
@click="clearError" <q-card style="width: max(400px, 25%); height: 600px; max-height: 80vh;">
/> <q-bar class="bg-primary text-white">
</template> <div>Chat</div>
</q-banner> <q-space />
</q-card> <q-btn
</q-dialog> dense
</q-layout> flat
</template> icon="close"
@click="toggleChat"
<script setup> />
import axios from 'boot/axios'; </q-bar>
import { ref, computed } from 'vue'; // Import computed
import { useRouter } from 'vue-router'; <q-card-section
import { useQuasar } from 'quasar'; class="q-pa-none"
import { useAuthStore } from 'stores/auth'; // Import the auth store style="height: calc(100% - 50px);"
import { useChatStore } from 'stores/chat'; // Adjust path as needed >
import ChatInterface from 'components/ChatInterface.vue'; // Adjust path as needed <ChatInterface
import routes from '../router/routes'; // Import routes :messages="chatMessages"
@send-message="handleSendMessage"
const $q = useQuasar(); />
const leftDrawerOpen = ref(false); </q-card-section>
const router = useRouter(); <q-inner-loading :showing="isLoading">
const authStore = useAuthStore(); // Use the auth store <q-spinner-gears
const chatStore = useChatStore(); size="50px"
color="primary"
const fabOpen = ref(false); // Local state for FAB animation, not chat visibility />
</q-inner-loading>
// Computed properties to get state from the store <q-banner
const isChatVisible = computed(() => chatStore.isChatVisible); v-if="chatError"
const chatMessages = computed(() => chatStore.chatMessages); inline-actions
const isLoading = computed(() => chatStore.isLoading); class="text-white bg-red"
const chatError = computed(() => chatStore.error); >
const isAuthenticated = computed(() => authStore.isAuthenticated); // Get auth state {{ chatError }}
<template #action>
// Get the child routes of the main layout <q-btn
const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || []; flat
color="white"
// Compute navigation items based on auth state and route meta label="Dismiss"
const navItems = computed(() => @click="clearError"
{ />
return mainLayoutRoutes.filter(route => </template>
{ </q-banner>
const navGroup = route.meta?.navGroup; </q-card>
if (!navGroup) return false; // Only include routes with navGroup defined </q-dialog>
</q-layout>
if (navGroup === 'always') return true; </template>
if (navGroup === 'auth' && isAuthenticated.value) return true;
if (navGroup === 'noAuth' && !isAuthenticated.value) return true; <script setup>
import axios from 'boot/axios';
return false; // Exclude otherwise import { ref, computed } from 'vue'; // Import computed
}); import { useRouter } from 'vue-router';
}); import { useQuasar } from 'quasar';
import { useAuthStore } from 'stores/auth'; // Import the auth store
import { useChatStore } from 'stores/chat'; // Adjust path as needed
// Method to toggle chat visibility via the store action import ChatInterface from 'components/ChatInterface.vue'; // Adjust path as needed
const toggleChat = () => import routes from '../router/routes'; // Import routes
{
// Optional: Add an extra check here if needed, though hiding the button is primary const $q = useQuasar();
if (isAuthenticated.value) const leftDrawerOpen = ref(false);
{ const router = useRouter();
chatStore.toggleChat(); const authStore = useAuthStore(); // Use the auth store
} const chatStore = useChatStore();
};
const fabOpen = ref(false); // Local state for FAB animation, not chat visibility
// Method to send a message via the store action
const handleSendMessage = (messageContent) => // Computed properties to get state from the store
{ const isChatVisible = computed(() => chatStore.isChatVisible);
chatStore.sendMessage(messageContent); const chatMessages = computed(() => chatStore.chatMessages);
}; const isLoading = computed(() => chatStore.isLoading);
const chatError = computed(() => chatStore.error);
// Method to clear errors in the store (optional) const isAuthenticated = computed(() => authStore.isAuthenticated); // Get auth state
const clearError = () =>
{ // Get the child routes of the main layout
chatStore.error = null; // Directly setting ref or add an action in store const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || [];
};
function toggleLeftDrawer() // Compute navigation items based on auth state and route meta
{ const navItems = computed(() =>
leftDrawerOpen.value = !leftDrawerOpen.value; {
} return mainLayoutRoutes.filter(route =>
{
async function logout() const navGroup = route.meta?.navGroup;
{ if (!navGroup) return false; // Only include routes with navGroup defined
try
{ if (navGroup === 'always') return true;
await axios.post('/api/auth/logout'); if (navGroup === 'auth' && isAuthenticated.value) return true;
authStore.logout(); // Use the store action to update state if (navGroup === 'noAuth' && !isAuthenticated.value) return true;
// No need to manually push, router guard should redirect
// router.push({ name: 'login' }); return false; // Exclude otherwise
} });
catch (error) });
{
console.error('Logout failed:', error);
// Method to toggle chat visibility via the store action
$q.notify({ const toggleChat = () =>
color: 'negative', {
message: 'Logout failed. Please try again.', // Optional: Add an extra check here if needed, though hiding the button is primary
icon: 'report_problem' if (isAuthenticated.value)
}); {
} chatStore.toggleChat();
} }
</script> };
<style scoped> // Method to send a message via the store action
/* Add any specific styles for the layout or chat window here */ const handleSendMessage = (messageContent) =>
.q-dialog .q-card { {
overflow: hidden; /* Prevent scrollbars on the card itself */ chatStore.sendMessage(messageContent);
} };
</style>
// Method to clear errors in the store (optional)
const clearError = () =>
{
chatStore.error = null; // Directly setting ref or add an action in store
};
function toggleLeftDrawer()
{
leftDrawerOpen.value = !leftDrawerOpen.value;
}
async function logout()
{
$q.dialog({
title: 'Confirm Logout',
message: 'Are you sure you want to logout?',
cancel: true,
persistent: true
}).onOk(async() =>
{
try
{
await axios.post('/api/auth/logout');
authStore.logout();
$q.notify({
color: 'positive',
message: 'Logout successful.',
icon: 'check_circle'
});
router.push({ name: 'login' });
}
catch (error)
{
console.error('Logout failed:', error);
$q.notify({
color: 'negative',
message: 'Logout failed. Please try again.',
icon: 'report_problem'
});
}
});
}
</script>
<style lang="scss" scoped>
.q-dialog .q-card {
overflow: hidden;
}
.menu-list .q-item {
border-radius: 32px;
margin: 5px 5px;
}
.menu-list .q-item:first-child {
margin-top: 0px;
}
.menu-list .q-router-link--active {
background-color: var(--q-primary);
color: #fff;
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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