Overhaul settings and implement user preferences. Also implements dark theme toggle as part of the user settings.

This commit is contained in:
Cameron Redmore 2025-04-25 17:32:33 +01:00
parent b84f0907a8
commit 727746030c
17 changed files with 760 additions and 378 deletions

View file

@ -90,7 +90,7 @@ export default defineConfig((/* ctx */) =>
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
framework: { framework: {
config: { config: {
dark: true dark: 'auto'
}, },
// iconSet: 'material-icons', // Quasar icon set // iconSet: 'material-icons', // Quasar icon set

View file

@ -5,3 +5,15 @@ 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;
// Helper function for consistent error handling
export const handlePrismaError = (res, err, context) =>
{
console.error(`Error ${context}:`, err.message);
// Basic error handling, can be expanded (e.g., check for Prisma-specific error codes)
if (err.code === 'P2025')
{ // Prisma code for record not found
return res.status(404).json({ error: `${context}: Record not found` });
}
res.status(500).json({ error: `Failed to ${context}: ${err.message}` });
};

View file

@ -1,5 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import prisma from '../database.js'; import prisma from '../database.js';
import { handlePrismaError } from '../database.js';
import PDFDocument from 'pdfkit'; import PDFDocument from 'pdfkit';
import { join } from 'path'; import { join } from 'path';
import { generateTodaysSummary } from '../services/mantisSummarizer.js'; import { generateTodaysSummary } from '../services/mantisSummarizer.js';
@ -9,18 +10,6 @@ const router = Router();
const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, ''); const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, '');
// Helper function for consistent error handling
const handlePrismaError = (res, err, context) =>
{
console.error(`Error ${context}:`, err.message);
// Basic error handling, can be expanded (e.g., check for Prisma-specific error codes)
if (err.code === 'P2025')
{ // Prisma code for record not found
return res.status(404).json({ error: `${context}: Record not found` });
}
res.status(500).json({ error: `Failed to ${context}: ${err.message}` });
};
// --- Forms API --- // // --- Forms API --- //
// GET /api/forms - List all forms // GET /api/forms - List all forms
@ -647,59 +636,4 @@ router.post('/mantis-summaries/generate', async(req, res) =>
} }
}); });
// --- Settings API --- //
// GET /api/settings/:key - Get a specific setting value
router.get('/settings/:key', async(req, res) =>
{
const { key } = req.params;
try
{
const setting = await prisma.setting.findUnique({
where: { key: key },
select: { value: true }
});
if (setting !== null)
{
res.json({ key, value: setting.value });
}
else
{
res.json({ key, value: '' }); // Return empty value if not found
}
}
catch (err)
{
handlePrismaError(res, err, `fetch setting '${key}'`);
}
});
// PUT /api/settings/:key - Update or create a specific setting
router.put('/settings/:key', async(req, res) =>
{
const { key } = req.params;
const { value } = req.body;
if (typeof value === 'undefined')
{
return res.status(400).json({ error: 'Setting value is required in the request body' });
}
try
{
const upsertedSetting = await prisma.setting.upsert({
where: { key: key },
update: { value: String(value) },
create: { key: key, value: String(value) },
select: { key: true, value: true } // Select to return the updated/created value
});
res.status(200).json(upsertedSetting);
}
catch (err)
{
handlePrismaError(res, err, `update setting '${key}'`);
}
});
export default router; export default router;

View file

@ -48,7 +48,8 @@ async function getAuthenticatorByCredentialID(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; // Destructure username, email, and fullName from the request body
const { username, email, fullName } = req.body;
if (!username) if (!username)
{ {
@ -59,13 +60,18 @@ router.post('/generate-registration-options', async(req, res) =>
{ {
let user = await getUserByUsername(username); let user = await getUserByUsername(username);
// If user doesn't exist, create one // If user doesn't exist, create one with the provided details
if (!user) if (!user)
{ {
const userData = { username };
if (email) userData.email = email; // Add email if provided
if (fullName) userData.fullName = fullName; // Add fullName if provided
user = await prisma.user.create({ user = await prisma.user.create({
data: { username }, data: userData,
}); });
} }
// ... rest of the existing logic ...
const userAuthenticators = await getUserAuthenticators(user.id); const userAuthenticators = await getUserAuthenticators(user.id);
@ -107,6 +113,11 @@ router.post('/generate-registration-options', async(req, res) =>
catch (error) catch (error)
{ {
console.error('Registration options error:', error); console.error('Registration options error:', error);
// Handle potential Prisma unique constraint errors (e.g., email already exists)
if (error.code === 'P2002' && error.meta?.target?.includes('email'))
{
return res.status(409).json({ error: 'Email address is already in use.' });
}
res.status(500).json({ error: 'Failed to generate registration options' }); res.status(500).json({ error: 'Failed to generate registration options' });
} }
}); });
@ -190,12 +201,18 @@ router.post('/verify-registration', async(req, res) =>
} }
else else
{ {
// This else block was previously misplaced before the if block
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);
// Handle potential Prisma unique constraint errors (e.g., email already exists)
if (error.code === 'P2002' && error.meta?.target?.includes('email'))
{
return res.status(409).json({ error: 'Email address is already in use.' });
}
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 });
@ -437,7 +454,8 @@ router.get('/status', async(req, res) =>
{}); {});
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 } }); // Include email and fullName in the response
return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email, fullName: user.fullName } });
} }
res.json({ status: 'unauthenticated' }); res.json({ status: 'unauthenticated' });
}); });

View file

@ -0,0 +1,51 @@
import express from 'express';
import { getSetting, setSetting, getUserPreference, setUserPreference } from '../utils/settings.js';
import { handlePrismaError } from '../database.js';
const router = express.Router();
// --- Settings API --- //
// GET /api/settings/:key - Get a specific setting value
router.get('/:key', async(req, res) =>
{
const { key } = req.params;
try
{
const setting = await getSetting(key);
if (!setting)
{
return res.status(404).json({ error: `Setting '${key}' not found` });
}
res.status(200).json(setting);
}
catch (err)
{
handlePrismaError(res, err, `fetch setting '${key}'`);
}
});
// PUT /api/settings/:key - Update or create a specific setting
router.put('/:key', async(req, res) =>
{
const { key } = req.params;
const { value } = req.body;
if (typeof value === 'undefined')
{
return res.status(400).json({ error: 'Setting value is required in the request body' });
}
try
{
await setSetting(key, value);
res.status(200).json({ message: `Setting '${key}' updated successfully` });
}
catch (err)
{
handlePrismaError(res, err, `update setting '${key}'`);
}
});
export default router;

View file

@ -0,0 +1,57 @@
import express from 'express';
import { getUserPreference, setUserPreference } from '../utils/settings.js';
const router = express.Router();
// GET /api/user-preferences/:key - Get a user preference for the logged-in user
router.get('/:key', async(req, res) =>
{
const { key } = req.params;
const userId = req.session?.loggedInUserId;
if (!userId)
{
return res.status(401).json({ error: 'Not authenticated' });
}
try
{
const value = await getUserPreference(userId, key);
if (typeof value === 'undefined' || value === null)
{
return res.status(404).json({ error: `Preference '${key}' not found for user` });
}
res.status(200).json({ key, value });
}
catch (err)
{
handlePrismaError(res, err, `fetch user preference '${key}'`);
}
});
// PUT /api/user-preferences/:key - Set a user preference for the logged-in user
router.put('/:key', async(req, res) =>
{
const { key } = req.params;
const { value } = req.body;
const userId = req.session?.loggedInUserId;
if (!userId)
{
return res.status(401).json({ error: 'Not authenticated' });
}
if (typeof value === 'undefined')
{
return res.status(400).json({ error: 'Preference value is required in the request body' });
}
try
{
await setUserPreference(userId, key, value);
res.status(200).json({ message: `Preference '${key}' updated for user` });
}
catch (err)
{
handlePrismaError(res, err, `update user preference '${key}'`);
}
});
export default router;

View file

@ -12,27 +12,30 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import express from 'express'; import express from 'express';
import compression from 'compression'; import compression from 'compression';
import session from 'express-session'; // Added for session management import session from 'express-session';
import { PrismaSessionStore } from '@quixo3/prisma-session-store'; // Import Prisma session store import { PrismaSessionStore } from '@quixo3/prisma-session-store';
import { PrismaClient } from '@prisma/client'; // Import Prisma Client import { PrismaClient } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid'; // Added for generating session IDs import { v4 as uuidv4 } from 'uuid';
import apiRoutes from './routes/api.js'; import apiRoutes from './routes/api.js';
import authRoutes from './routes/auth.js'; // Added for WebAuthn routes import authRoutes from './routes/auth.js';
import chatRoutes from './routes/chat.js'; // Added for Chat routes import chatRoutes from './routes/chat.js';
import settingsRoutes from './routes/settings.js';
import userPreferencesRoutes from './routes/userPreferences.js';
import cron from 'node-cron'; import cron from 'node-cron';
import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js'; import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
import { requireAuth } from './middlewares/authMiddleware.js';
dotenv.config(); dotenv.config();
// Define Relying Party details (Update with your actual details) // Define Relying Party details (Update with your actual details)
export const rpID = process.env.NODE_ENV === 'production' ? 'your-production-domain.com' : 'localhost'; export const rpID = process.env.NODE_ENV === 'production' ? 'stylepoint.uk' : 'localhost';
export const rpName = 'StylePoint'; export const rpName = 'StylePoint';
export const origin = process.env.NODE_ENV === 'production' ? `https://${rpID}` : `http://${rpID}:9000`; export const origin = process.env.NODE_ENV === 'production' ? `https://${rpID}` : `http://${rpID}:9000`;
// In-memory store for challenges (Replace with a persistent store in production) // In-memory store for challenges (Replace with a persistent store in production)
export const challengeStore = new Map(); export const challengeStore = new Map();
const prisma = new PrismaClient(); // Instantiate Prisma Client const prisma = new PrismaClient();
const app = express(); const app = express();
@ -90,12 +93,12 @@ app.disable('x-powered-by');
app.use(express.json()); app.use(express.json());
// Add API routes // Add API routes
app.use('/api', apiRoutes);
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/chat', chatRoutes); app.use('/api/chat', requireAuth, chatRoutes);
app.use('/api/user-preferences', requireAuth, userPreferencesRoutes);
app.use('/api/settings', requireAuth, settingsRoutes);
app.use('/api', requireAuth, apiRoutes);
// place here any middlewares that
// absolutely need to run before anything else
if (process.env.PROD) if (process.env.PROD)
{ {
app.use(compression()); app.use(compression());

View file

@ -99,16 +99,11 @@ export async function generateAndStoreMantisSummary()
{ {
try try
{ {
// Get the prompt from the database settings using Prisma const promptTemplate = await getSetting('MANTIS_PROMPT');
const setting = await prisma.setting.findUnique({
where: { key: 'mantisPrompt' },
select: { value: true }
});
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: MANTIS_PROMPT). Skipping summary generation.');
return; return;
} }

View file

@ -7,14 +7,44 @@ export async function getSetting(key)
select: { value: true } select: { value: true }
}); });
console.log(`getSetting(${key})`, setting);
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)
{ {
//Replace all CRLFs with LF
if (typeof value === 'string')
{
value = value.replace(/\r\n/g, '\n').trim();
}
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: JSON.stringify(value) }
});
}
export async function getUserPreference(userId, key)
{
const pref = await prisma.userPreference.findUnique({
where: { userId_key: { userId, key } },
select: { value: true }
});
return pref?.value ? JSON.parse(pref.value) : null;
}
export async function setUserPreference(userId, key, value)
{
if (typeof value === 'string')
{
value = value.replace(/\r\n/g, '\n').trim();
}
await prisma.userPreference.upsert({
where: { userId_key: { userId, key } },
update: { value: JSON.stringify(value) },
create: { userId, key, value: JSON.stringify(value) }
}); });
} }

View file

@ -4,12 +4,22 @@
<script setup> <script setup>
import { useAuthStore } from './stores/auth'; import { useAuthStore } from './stores/auth';
import { usePreferencesStore } from './stores/preferences';
defineOptions({ import { onMounted } from 'vue';
preFetch()
const authStore = useAuthStore();
const preferencesStore = usePreferencesStore();
onMounted(async() =>
{
// Check if user is authenticated
if (!authStore.isAuthenticated)
{ {
const authStore = useAuthStore(); authStore.checkAuthStatus();
return authStore.checkAuthStatus();
} }
// Load user preferences
await preferencesStore.loadPreferences();
}); });
</script> </script>

View file

@ -25,9 +25,20 @@
v-if="leftDrawerOpen" v-if="leftDrawerOpen"
bordered bordered
flat flat
class="q-ma-sm text-center" class="q-ma-sm text-center relative"
> >
<q-card-section> <q-card-section>
<q-btn
class="absolute"
style="top: 10px; right: 10px;"
flat
round
:to="{ name: 'userPreferences' }"
>
<q-icon
name="settings"
/>
</q-btn>
<q-avatar <q-avatar
class="bg-primary cursor-pointer text-white" class="bg-primary cursor-pointer text-white"
> >
@ -123,7 +134,7 @@
:offset="[18, 18]" :offset="[18, 18]"
> >
<q-fab <q-fab
v-model="chatStore.isChatVisible" v-model="fabOpen"
icon="chat" icon="chat"
color="accent" color="accent"
direction="up" direction="up"
@ -200,6 +211,8 @@ const router = useRouter();
const authStore = useAuthStore(); // Use the auth store const authStore = useAuthStore(); // Use the auth store
const chatStore = useChatStore(); const chatStore = useChatStore();
const fabOpen = ref(false); // Local state for FAB animation, not chat visibility
// Computed properties to get state from the store // Computed properties to get state from the store
const isChatVisible = computed(() => chatStore.isChatVisible); const isChatVisible = computed(() => chatStore.isChatVisible);
const chatMessages = computed(() => chatStore.chatMessages); const chatMessages = computed(() => chatStore.chatMessages);

View file

@ -26,7 +26,6 @@
</div> </div>
</div> </div>
<!-- Passkey List Section -->
<q-card-section> <q-card-section>
<h5>Your Registered Passkeys</h5> <h5>Your Registered Passkeys</h5>
<q-list <q-list
@ -61,14 +60,12 @@
> >
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-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 -->
<q-btn <q-btn
flat flat
dense dense
@ -125,9 +122,10 @@
<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 axios from 'boot/axios'; import axios from 'boot/axios';
import { useAuthStore } from 'stores/auth'; import { useAuthStore } from 'stores/auth';
import { useQuasar } from 'quasar';
const registerLoading = ref(false); const registerLoading = ref(false);
const registerErrorMessage = ref(''); const registerErrorMessage = ref('');
@ -137,27 +135,27 @@ 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);
const identifyErrorMessage = ref(''); const identifyErrorMessage = ref('');
const identifiedPasskeyId = ref(null); // Store the ID of the successfully identified passkey const identifiedPasskeyId = ref(null);
const $q = useQuasar();
const authStore = useAuthStore(); const authStore = useAuthStore();
const passkeys = ref([]); // To store the list of passkeys const passkeys = ref([]);
// 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
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 = '';
deleteErrorMessage.value = ''; deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; // Clear identify message identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; // Clear identified key identifiedPasskeyId.value = null;
try try
{ {
const response = await axios.get('/api/auth/passkeys'); const response = await axios.get('/api/auth/passkeys');
@ -167,7 +165,7 @@ async function fetchPasskeys()
{ {
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 = [];
} }
finally finally
{ {
@ -175,7 +173,6 @@ async function fetchPasskeys()
} }
} }
// Check auth status and fetch passkeys on component mount
onMounted(async() => onMounted(async() =>
{ {
let initialAuthError = ''; let initialAuthError = '';
@ -189,12 +186,11 @@ onMounted(async() =>
} }
if (!isLoggedIn.value) if (!isLoggedIn.value)
{ {
// Use register error message ref for consistency if login is required first
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.'; registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
} }
else else
{ {
fetchPasskeys(); // Fetch passkeys if logged in fetchPasskeys();
} }
}); });
@ -208,23 +204,20 @@ async function handleRegister()
registerLoading.value = true; registerLoading.value = true;
registerErrorMessage.value = ''; registerErrorMessage.value = '';
registerSuccessMessage.value = ''; registerSuccessMessage.value = '';
deleteSuccessMessage.value = ''; // Clear other messages deleteSuccessMessage.value = '';
deleteErrorMessage.value = ''; deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; identifiedPasskeyId.value = null;
try try
{ {
// 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,
}); });
const options = optionsRes.data; const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options); const regResp = await startRegistration(options);
// 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,
}); });
@ -232,7 +225,7 @@ async function handleRegister()
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();
} }
else else
{ {
@ -243,7 +236,6 @@ async function handleRegister()
{ {
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
if (error.name === 'InvalidStateError') if (error.name === 'InvalidStateError')
{ {
registerErrorMessage.value = 'Authenticator may already be registered.'; registerErrorMessage.value = 'Authenticator may already be registered.';
@ -268,42 +260,63 @@ async function handleRegister()
} }
// Handle deleting a passkey
async function handleDelete(credentialID) async function handleDelete(credentialID)
{ {
if (!credentialID) return; if (!credentialID) return;
// Optional: Add a confirmation dialog here if (passkeys.value.length <= 1)
// if (!confirm('Are you sure you want to delete this passkey?')) { {
// return; deleteErrorMessage.value = 'You cannot delete your last passkey. Register another one first.';
// } deleteSuccessMessage.value = '';
registerSuccessMessage.value = '';
registerErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
return;
}
deleteLoading.value = credentialID; // Set loading state for the specific button $q.dialog({
deleteErrorMessage.value = ''; title: 'Confirm Deletion',
deleteSuccessMessage.value = ''; message: 'Are you sure you want to delete this passkey? This action cannot be undone.',
registerSuccessMessage.value = ''; // Clear other messages cancel: true,
registerErrorMessage.value = ''; persistent: true,
identifyErrorMessage.value = ''; ok: {
identifiedPasskeyId.value = null; label: 'Delete',
color: 'negative',
flat: true,
},
cancel: {
label: 'Cancel',
flat: true,
},
}).onOk(async() =>
{
deleteLoading.value = credentialID;
deleteErrorMessage.value = '';
deleteSuccessMessage.value = '';
registerSuccessMessage.value = '';
registerErrorMessage.value = '';
identifyErrorMessage.value = '';
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();
} }
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;
} }
});
} }
// Handle identifying a passkey
async function handleIdentify() async function handleIdentify()
{ {
if (!isLoggedIn.value) if (!isLoggedIn.value)
@ -314,8 +327,7 @@ async function handleIdentify()
identifyLoading.value = true; identifyLoading.value = true;
identifyErrorMessage.value = ''; identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; // Reset identified key identifiedPasskeyId.value = null;
// Clear other messages
registerSuccessMessage.value = ''; registerSuccessMessage.value = '';
registerErrorMessage.value = ''; registerErrorMessage.value = '';
deleteSuccessMessage.value = ''; deleteSuccessMessage.value = '';
@ -323,30 +335,21 @@ async function handleIdentify()
try try
{ {
// 1. Get authentication options from the server const optionsRes = await axios.post('/api/auth/generate-authentication-options', {});
// We don't need to send username as the server should use the session
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {}); // Send empty body
const options = optionsRes.data; const options = optionsRes.data;
// Optionally filter options to only allow the specific key if needed, but usually not necessary for identification
// options.allowCredentials = options.allowCredentials?.filter(cred => cred.id === credentialIDToIdentify);
// 2. Start authentication ceremony in the browser
const authResp = await startAuthentication(options); const authResp = await startAuthentication(options);
// 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
setTimeout(() => setTimeout(() =>
{ {
// 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);
} }
catch (error) catch (error)
@ -364,7 +367,7 @@ async function handleIdentify()
} }
finally finally
{ {
identifyLoading.value = null; // Clear loading state identifyLoading.value = null;
} }
} }

View file

@ -2,9 +2,9 @@
<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 -->
<div class="text-h6"> <div class="text-h6">
{{ isLoggedIn ? 'Register New Passkey' : 'Register Passkey' }} Register Passkey
</div> </div>
</q-card-section> </q-card-section>
@ -13,21 +13,35 @@
v-model="username" v-model="username"
label="Username" label="Username"
outlined outlined
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" />
:hint="isLoggedIn ? 'Registering a new passkey for your current account.' : ''" <!-- Make Email and Full Name required -->
:readonly="isLoggedIn" <q-input
v-model="email"
label="Email"
type="email"
outlined
class="q-mb-md"
:rules="[val => !!val || 'Email is required', val => /.+@.+\..+/.test(val) || 'Email must be valid']"
@keyup.enter="handleRegister"
/>
<q-input
v-model="fullName"
label="Full Name"
outlined
class="q-mb-md"
:rules="[val => !!val || 'Full Name is required']"
@keyup.enter="handleRegister"
/> />
<q-btn <q-btn
:label="isLoggedIn ? 'Register New Passkey' : 'Register Passkey'" label="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 || !email || !fullName"
/> />
<div <div
v-if="successMessage" v-if="successMessage"
@ -44,9 +58,8 @@
</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 --> <!-- Always show login link -->
<q-btn <q-btn
v-if="!isLoggedIn"
flat flat
label="Already have an account? Login" label="Already have an account? Login"
to="/login" to="/login"
@ -57,63 +70,56 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue'; // Import computed import { ref } from 'vue'; // Remove computed and onMounted
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 // Remove auth store import
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 // Remove auth store usage
// Computed properties to get state from the store // Remove isLoggedIn computed property
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = ref(''); // Local ref for username input const username = ref('');
const email = ref('');
const fullName = ref('');
// Check auth status on component mount using the store action // Remove onMounted hook
onMounted(async() =>
{
if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus();
if (authStore.error)
{
errorMessage.value = authStore.error;
}
}
if (!isLoggedIn.value)
{
username.value = ''; // Clear username if not logged in
}
else
{
username.value = authStore.user?.username || ''; // Use username from store if logged in
}
});
async function handleRegister() async function handleRegister()
{ {
const currentUsername = isLoggedIn.value ? authStore.user?.username : username.value; // Validate all fields
if (!currentUsername) if (!username.value || !email.value || !fullName.value)
{ {
errorMessage.value = 'Username is missing.'; errorMessage.value = 'Please fill in all required fields.';
return; return;
} }
// Basic email validation
if (!/.+@.+\..+/.test(email.value))
{
errorMessage.value = 'Please enter a valid email address.';
return;
}
loading.value = true; loading.value = true;
errorMessage.value = ''; errorMessage.value = '';
successMessage.value = ''; successMessage.value = '';
try try
{ {
// Prepare payload - always include all fields
const payload = {
username: username.value,
email: email.value,
fullName: fullName.value,
};
// 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', payload);
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
@ -126,23 +132,12 @@ async function handleRegister()
if (verificationRes.data.verified) if (verificationRes.data.verified)
{ {
// Adjust success message based on login state // Simplify success message and always redirect
successMessage.value = isLoggedIn.value successMessage.value = 'Registration successful! Redirecting to login...';
? 'New passkey registered successfully!' setTimeout(() =>
: 'Registration successful! Redirecting to login...';
if (!isLoggedIn.value)
{ {
// Redirect to login page only if they weren't logged in router.push('/login');
setTimeout(() => }, 2000);
{
router.push('/login');
}, 2000);
}
else
{
// Maybe redirect to a profile page or dashboard if already logged in
// setTimeout(() => { router.push('/dashboard'); }, 2000);
}
} }
else else
{ {
@ -164,7 +159,8 @@ async function handleRegister()
} }
else if (error.response?.status === 409) else if (error.response?.status === 409)
{ {
errorMessage.value = 'This passkey seems to be registered already.'; // More specific message for username conflict
errorMessage.value = error.response?.data?.error || 'Username or passkey might already be registered.';
} }
else else
{ {

View file

@ -1,202 +1,220 @@
<template> <template>
<q-page padding> <q-page padding>
<div <q-card class="q-mb-md">
class="q-gutter-md" <q-card-section>
style="max-width: 800px; margin: auto;" <div class="text-h6">
> Application Settings
<h5 class="q-mt-none q-mb-md"> </div>
Settings </q-card-section>
</h5>
<q-card <q-separator />
flat
bordered
>
<q-card-section>
<div class="text-h6">
Mantis Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders.
</div>
<q-input
v-model="mantisPrompt"
type="textarea"
filled
autogrow
label="Mantis Prompt"
:loading="loadingPrompt"
:disable="savingPrompt"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Save Prompt"
color="primary"
@click="saveMantisPrompt"
:loading="savingPrompt"
:disable="!mantisPrompt || loadingPrompt"
/>
</q-card-actions>
</q-card>
<q-card <q-card-section v-if="loading">
flat <q-spinner-dots size="2em" /> Loading settings...
bordered </q-card-section>
>
<q-card-section> <q-card-section v-else-if="loadError">
<div class="text-h6"> <q-banner
Email Summary Prompt inline-actions
class="text-white bg-red"
>
<template #avatar>
<q-icon name="error" />
</template>
{{ loadError }}
<template #action>
<q-btn
flat
color="white"
label="Retry"
@click="loadSettings"
/>
</template>
</q-banner>
</q-card-section>
<q-card-section v-else>
<q-form @submit.prevent="saveSettings">
<div
v-for="(group, groupName) in settings"
:key="groupName"
class="q-mb-lg"
>
<div class="text-h6 q-mb-sm">
{{ groupName }}
</div>
<div
v-for="setting in group"
:key="setting.key"
class="q-mb-md"
>
<q-input
v-model="settingValues[setting.key]"
:label="setting.name"
:type="setting.type || 'text'"
outlined
dense
/>
</div>
<q-separator class="q-my-md" />
</div> </div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array. <div class="row justify-end">
<q-btn
label="Save Settings"
type="submit"
color="primary"
:loading="saving"
:disable="loading || saving"
/>
</div> </div>
<q-input </q-form>
v-model="emailPrompt" </q-card-section>
type="textarea" </q-card>
filled
autogrow
label="Email Prompt"
:loading="loadingEmailPrompt"
:disable="savingEmailPrompt"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Save Prompt"
color="primary"
@click="saveEmailPrompt"
:loading="savingEmailPrompt"
:disable="!emailPrompt || loadingEmailPrompt"
/>
</q-card-actions>
</q-card>
</div>
</q-page> </q-page>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import axios from 'boot/axios'; import { ref, onMounted } from 'vue';
import axios from 'boot/axios'; // Import axios
const $q = useQuasar(); const $q = useQuasar();
const mantisPrompt = ref(''); // Define the structure of settings
const loadingPrompt = ref(false); const settings = ref({
const savingPrompt = ref(false); Mantis: [
{
name: 'Mantis API Key',
key: 'MANTIS_API_KEY',
},
{
name: 'Mantis API Endpoint',
key: 'MANTIS_API_ENDPOINT'
},
{
name: 'Mantis Prompt',
key: 'MANTIS_PROMPT',
type: 'textarea'
}
],
Gemini: [
{
name: 'Gemini API Key',
key: 'GEMINI_API_KEY'
}
],
Database: [
{
name: 'MySQL Host',
key: 'MYSQL_HOST'
},
{
name: 'MySQL Port',
key: 'MYSQL_PORT'
},
{
name: 'MySQL User',
key: 'MYSQL_USER'
},
{
name: 'MySQL Password',
key: 'MYSQL_PASSWORD'
},
{
name: 'MySQL Database',
key: 'MYSQL_DATABASE'
}
]
});
const fetchMantisPrompt = async() => // Reactive state for setting values, loading, saving, and errors
const settingValues = ref({});
const loading = ref(true);
const saving = ref(false);
const loadError = ref(null);
// Function to load settings from the server
async function loadSettings()
{ {
loadingPrompt.value = true; loading.value = true;
loadError.value = null;
settingValues.value = {}; // Reset values
const allSettingKeys = Object.values(settings.value).flat().map(s => s.key);
try try
{ {
const response = await axios.get('/api/settings/mantisPrompt'); const responses = await Promise.all(allSettingKeys.map(key => axios.get(`/api/settings/${key}`, {
mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet validateStatus: status => status === 200 || status === 404 // Accept 404 as a valid response
})));
responses.forEach((response, index) =>
{
const key = allSettingKeys[index];
//If the response status is 404, set the value to an empty string
if (response.status === 404)
{
settingValues.value[key] = '';
return;
}
settingValues.value[key] = response.data;
});
} }
catch (error) catch (err)
{ {
console.error('Error fetching Mantis prompt:', error); console.error('Error loading settings:', err);
loadError.value = err.response?.data?.error || 'Failed to load settings. Please check the console.';
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
message: 'Failed to load Mantis prompt setting.', icon: 'error',
icon: 'report_problem' message: loadError.value,
}); });
} }
finally finally
{ {
loadingPrompt.value = false; loading.value = false;
} }
}; }
const saveMantisPrompt = async() => // Function to save settings to the server
async function saveSettings()
{ {
savingPrompt.value = true; saving.value = true;
loadError.value = null; // Clear previous load errors
const allSettingKeys = Object.keys(settingValues.value);
const requests = allSettingKeys.map(key =>
axios.put(`/api/settings/${key}`, { value: settingValues.value[key] })
);
try try
{ {
await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value }); await Promise.all(requests);
$q.notify({ $q.notify({
color: 'positive', color: 'positive',
message: 'Mantis prompt updated successfully.', icon: 'check_circle',
icon: 'check_circle' message: 'Settings saved successfully!',
}); });
} }
catch (error) catch (err)
{ {
console.error('Error saving Mantis prompt:', error); console.error('Error saving settings:', err);
const errorMessage = err.response?.data?.error || 'Failed to save settings. Please try again.';
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
message: 'Failed to save Mantis prompt setting.', icon: 'error',
icon: 'report_problem' message: errorMessage,
}); });
} }
finally finally
{ {
savingPrompt.value = false; saving.value = false;
} }
}; }
const emailPrompt = ref('');
const loadingEmailPrompt = ref(false);
const savingEmailPrompt = ref(false);
const fetchEmailPrompt = async() =>
{
loadingEmailPrompt.value = true;
try
{
const response = await axios.get('/api/settings/emailPrompt');
emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
}
catch (error)
{
console.error('Error fetching Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to load Email prompt setting.',
icon: 'report_problem'
});
}
finally
{
loadingEmailPrompt.value = false;
}
};
const saveEmailPrompt = async() =>
{
savingEmailPrompt.value = true;
try
{
await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
$q.notify({
color: 'positive',
message: 'Email prompt updated successfully.',
icon: 'check_circle'
});
}
catch (error)
{
console.error('Error saving Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to save Email prompt setting.',
icon: 'report_problem'
});
}
finally
{
savingEmailPrompt.value = false;
}
};
// Load settings when the component is mounted
onMounted(() => onMounted(() =>
{ {
fetchMantisPrompt(); loadSettings();
fetchEmailPrompt();
}); });
</script> </script>
<style scoped>
/* Add any specific styles if needed */
</style>

View file

@ -0,0 +1,119 @@
<template>
<q-page padding>
<q-card class="q-mb-md">
<q-card-section>
<div class="text-h6">
User Preferences
</div>
</q-card-section>
<q-separator />
<q-card-section v-if="loading">
<q-spinner-dots size="2em" /> Loading preferences...
</q-card-section>
<q-card-section v-else-if="error">
<q-banner
inline-actions
class="text-white bg-red"
>
<template #avatar>
<q-icon name="error" />
</template>
{{ error }}
<template #action>
<q-btn
flat
color="white"
label="Retry"
@click="loadPreferences"
/>
</template>
</q-banner>
</q-card-section>
<q-card-section v-else>
<q-form @submit.prevent="savePreferences">
<div
v-for="(group, groupName) in preferences"
:key="groupName"
class="q-mb-lg"
>
<div class="text-h6 q-mb-sm">
{{ groupName.replaceAll('_', ' ') }}
</div>
<div
v-for="pref in group"
:key="pref.key"
class="q-mb-md"
>
<q-select
v-if="pref.options"
v-model="preferenceValues[pref.key]"
:label="pref.name"
:options="pref.options"
outlined
dense
emit-value
map-options
/>
<q-input
v-else
v-model="preferenceValues[pref.key]"
:label="pref.name"
:type="pref.type || 'text'"
outlined
dense
/>
</div>
<q-separator class="q-my-md" />
</div>
<div class="row justify-end">
<q-btn
label="Save Preferences"
type="submit"
color="primary"
:loading="saving"
:disable="loading || saving"
/>
</div>
</q-form>
</q-card-section>
</q-card>
</q-page>
</template>
<script setup>
import { useQuasar } from 'quasar';
import { onMounted, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { usePreferencesStore } from 'stores/preferences';
const $q = useQuasar();
const preferencesStore = usePreferencesStore();
const { preferences, values: preferenceValues, loading, saving, error } = storeToRefs(preferencesStore);
function notifyError(msg)
{
$q.notify({ color: 'negative', icon: 'error', message: msg });
}
function notifySuccess(msg)
{
$q.notify({ color: 'positive', icon: 'check_circle', message: msg });
}
async function loadPreferences()
{
await preferencesStore.loadPreferences();
if (error.value) notifyError(error.value);
}
async function savePreferences()
{
await preferencesStore.savePreferences();
if (error.value) notifyError(error.value);
else notifySuccess('Preferences saved!');
}
onMounted(() =>
{
loadPreferences();
});
</script>

View file

@ -85,6 +85,14 @@ const routes = [
title: 'Settings', title: 'Settings',
caption: 'Manage application settings' caption: 'Manage application settings'
} }
},
{
path: 'user-preferences',
name: 'userPreferences',
component: () => import('pages/UserPreferencesPage.vue'),
meta: {
requiresAuth: true
}
} }
] ]
}, },

115
src/stores/preferences.js Normal file
View file

@ -0,0 +1,115 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import axios from 'boot/axios';
import { Dark } from 'quasar';
export const usePreferencesStore = defineStore('preferences', () =>
{
// Grouped user preferences structure (can be imported/shared if needed)
const preferences = ref({
UI: [
{
name: 'Theme',
key: 'theme',
type: 'text',
options: [
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
],
},
],
API_Tokens: [
{
name: 'Mantis API Key',
key: 'MANTIS_API_KEY',
type: 'text',
}
]
});
const values = ref({});
const loading = ref(false);
const saving = ref(false);
const error = ref(null);
async function loadPreferences()
{
loading.value = true;
error.value = null;
values.value = {};
const allKeys = Object.values(preferences.value).flat().map(p => p.key);
try
{
const responses = await Promise.all(
allKeys.map(key =>
axios.get(`/api/user-preferences/${key}`, {
validateStatus: status => status === 200 || status === 404,
})
)
);
responses.forEach((response, idx) =>
{
const key = allKeys[idx];
if (response.status === 404)
{
values.value[key] = '';
}
else
{
values.value[key] = response.data.value;
}
});
}
catch (err)
{
error.value = err.response?.data?.error || 'Failed to load preferences.';
}
finally
{
loading.value = false;
}
//If we have a "theme" preference, change it in Quasar
if (values.value.theme)
{
Dark.set(values.value.theme === 'dark');
}
}
async function savePreferences()
{
saving.value = true;
error.value = null;
const allKeys = Object.keys(values.value);
const requests = allKeys.map(key =>
axios.put(`/api/user-preferences/${key}`, { value: values.value[key] })
);
try
{
await Promise.all(requests);
}
catch (err)
{
error.value = err.response?.data?.error || 'Failed to save preferences.';
}
finally
{
saving.value = false;
}
if (values.value.theme)
{
Dark.set(values.value.theme === 'dark');
}
}
return {
preferences,
values,
loading,
saving,
error,
loadPreferences,
savePreferences,
};
});