diff --git a/quasar.config.js b/quasar.config.js index 3c2c539..0c26a16 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -90,7 +90,7 @@ export default defineConfig((/* ctx */) => // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework framework: { config: { - dark: true + dark: 'auto' }, // iconSet: 'material-icons', // Quasar icon set diff --git a/src-server/database.js b/src-server/database.js index 23261de..969ce7e 100644 --- a/src-server/database.js +++ b/src-server/database.js @@ -4,4 +4,16 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); // Export the Prisma Client instance for use in other modules -export default prisma; \ No newline at end of file +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}` }); +}; \ No newline at end of file diff --git a/src-server/routes/api.js b/src-server/routes/api.js index 8931d03..ed4a5fd 100644 --- a/src-server/routes/api.js +++ b/src-server/routes/api.js @@ -1,5 +1,6 @@ import { Router } from 'express'; import prisma from '../database.js'; +import { handlePrismaError } from '../database.js'; import PDFDocument from 'pdfkit'; import { join } from 'path'; import { generateTodaysSummary } from '../services/mantisSummarizer.js'; @@ -9,18 +10,6 @@ const router = Router(); 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 --- // // 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; \ No newline at end of file diff --git a/src-server/routes/auth.js b/src-server/routes/auth.js index 9dfe52b..58b1775 100644 --- a/src-server/routes/auth.js +++ b/src-server/routes/auth.js @@ -48,7 +48,8 @@ async function getAuthenticatorByCredentialID(credentialID) // Generate Registration Options 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) { @@ -59,13 +60,18 @@ router.post('/generate-registration-options', async(req, res) => { 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) { + 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({ - data: { username }, + data: userData, }); } + // ... rest of the existing logic ... const userAuthenticators = await getUserAuthenticators(user.id); @@ -107,6 +113,11 @@ router.post('/generate-registration-options', async(req, res) => catch (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' }); } }); @@ -190,12 +201,18 @@ router.post('/verify-registration', async(req, res) => } else { + // This else block was previously misplaced before the if block res.status(400).json({ error: 'Registration verification failed' }); } } catch (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 delete req.session.userId; 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.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' }); }); diff --git a/src-server/routes/settings.js b/src-server/routes/settings.js new file mode 100644 index 0000000..7ef0185 --- /dev/null +++ b/src-server/routes/settings.js @@ -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; \ No newline at end of file diff --git a/src-server/routes/userPreferences.js b/src-server/routes/userPreferences.js new file mode 100644 index 0000000..3179999 --- /dev/null +++ b/src-server/routes/userPreferences.js @@ -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; \ No newline at end of file diff --git a/src-server/server.js b/src-server/server.js index 10716c4..091acee 100644 --- a/src-server/server.js +++ b/src-server/server.js @@ -12,27 +12,30 @@ import dotenv from 'dotenv'; import express from 'express'; import compression from 'compression'; -import session from 'express-session'; // Added for session management -import { PrismaSessionStore } from '@quixo3/prisma-session-store'; // Import Prisma session store -import { PrismaClient } from '@prisma/client'; // Import Prisma Client -import { v4 as uuidv4 } from 'uuid'; // Added for generating session IDs +import session from 'express-session'; +import { PrismaSessionStore } from '@quixo3/prisma-session-store'; +import { PrismaClient } from '@prisma/client'; +import { v4 as uuidv4 } from 'uuid'; import apiRoutes from './routes/api.js'; -import authRoutes from './routes/auth.js'; // Added for WebAuthn routes -import chatRoutes from './routes/chat.js'; // Added for Chat routes +import authRoutes from './routes/auth.js'; +import chatRoutes from './routes/chat.js'; +import settingsRoutes from './routes/settings.js'; +import userPreferencesRoutes from './routes/userPreferences.js'; import cron from 'node-cron'; import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js'; +import { requireAuth } from './middlewares/authMiddleware.js'; dotenv.config(); // 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 origin = process.env.NODE_ENV === 'production' ? `https://${rpID}` : `http://${rpID}:9000`; // In-memory store for challenges (Replace with a persistent store in production) export const challengeStore = new Map(); -const prisma = new PrismaClient(); // Instantiate Prisma Client +const prisma = new PrismaClient(); const app = express(); @@ -90,12 +93,12 @@ app.disable('x-powered-by'); app.use(express.json()); // Add API routes -app.use('/api', apiRoutes); 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) { app.use(compression()); diff --git a/src-server/services/mantisSummarizer.js b/src-server/services/mantisSummarizer.js index a1d7381..04dac2c 100644 --- a/src-server/services/mantisSummarizer.js +++ b/src-server/services/mantisSummarizer.js @@ -99,16 +99,11 @@ export async function generateAndStoreMantisSummary() { try { - // Get the prompt from the database settings using Prisma - const setting = await prisma.setting.findUnique({ - where: { key: 'mantisPrompt' }, - select: { value: true } - }); - const promptTemplate = setting?.value; + const promptTemplate = await getSetting('MANTIS_PROMPT'); 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; } diff --git a/src-server/utils/settings.js b/src-server/utils/settings.js index aa8be85..7be3841 100644 --- a/src-server/utils/settings.js +++ b/src-server/utils/settings.js @@ -7,14 +7,44 @@ export async function getSetting(key) select: { value: true } }); + console.log(`getSetting(${key})`, setting); + return setting?.value ? JSON.parse(setting.value) : null; } 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({ where: { key }, 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) } }); } \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 1f003d1..b23b108 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,12 +4,22 @@ diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 32c6f80..e4bb704 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -25,9 +25,20 @@ v-if="leftDrawerOpen" bordered flat - class="q-ma-sm text-center" + class="q-ma-sm text-center relative" > + + + @@ -123,7 +134,7 @@ :offset="[18, 18]" > chatStore.isChatVisible); const chatMessages = computed(() => chatStore.chatMessages); diff --git a/src/pages/PasskeyManagementPage.vue b/src/pages/PasskeyManagementPage.vue index dd75876..0b19a59 100644 --- a/src/pages/PasskeyManagementPage.vue +++ b/src/pages/PasskeyManagementPage.vue @@ -26,7 +26,6 @@ - Your Registered Passkeys Verified just now! - - 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 { useAuthStore } from 'stores/auth'; +import { useQuasar } from 'quasar'; const registerLoading = ref(false); const registerErrorMessage = ref(''); @@ -137,27 +135,27 @@ const fetchErrorMessage = ref(''); const deleteLoading = ref(null); const deleteErrorMessage = ref(''); const deleteSuccessMessage = ref(''); -const identifyLoading = ref(null); // Store the ID of the passkey being identified +const identifyLoading = ref(null); 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 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 username = computed(() => authStore.user?.username); -// Fetch existing passkeys async function fetchPasskeys() { if (!isLoggedIn.value) return; fetchLoading.value = true; fetchErrorMessage.value = ''; - deleteSuccessMessage.value = ''; // Clear delete messages on refresh + deleteSuccessMessage.value = ''; deleteErrorMessage.value = ''; - identifyErrorMessage.value = ''; // Clear identify message - identifiedPasskeyId.value = null; // Clear identified key + identifyErrorMessage.value = ''; + identifiedPasskeyId.value = null; try { const response = await axios.get('/api/auth/passkeys'); @@ -167,7 +165,7 @@ async function fetchPasskeys() { console.error('Error fetching passkeys:', error); fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.'; - passkeys.value = []; // Clear passkeys on error + passkeys.value = []; } finally { @@ -175,7 +173,6 @@ async function fetchPasskeys() } } -// Check auth status and fetch passkeys on component mount onMounted(async() => { let initialAuthError = ''; @@ -189,12 +186,11 @@ onMounted(async() => } if (!isLoggedIn.value) { - // Use register error message ref for consistency if login is required first registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.'; } else { - fetchPasskeys(); // Fetch passkeys if logged in + fetchPasskeys(); } }); @@ -208,23 +204,20 @@ async function handleRegister() registerLoading.value = true; registerErrorMessage.value = ''; registerSuccessMessage.value = ''; - deleteSuccessMessage.value = ''; // Clear other messages + deleteSuccessMessage.value = ''; deleteErrorMessage.value = ''; identifyErrorMessage.value = ''; identifiedPasskeyId.value = null; try { - // 1. Get options from server const optionsRes = await axios.post('/api/auth/generate-registration-options', { - username: username.value, // Use username from store + username: username.value, }); const options = optionsRes.data; - // 2. Start registration ceremony in browser const regResp = await startRegistration(options); - // 3. Send response to server for verification const verificationRes = await axios.post('/api/auth/verify-registration', { registrationResponse: regResp, }); @@ -232,7 +225,7 @@ async function handleRegister() if (verificationRes.data.verified) { registerSuccessMessage.value = 'New passkey registered successfully!'; - fetchPasskeys(); // Refresh the list of passkeys + fetchPasskeys(); } else { @@ -243,7 +236,6 @@ async function handleRegister() { console.error('Registration error:', error); const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.'; - // Handle specific simplewebauthn errors if (error.name === 'InvalidStateError') { registerErrorMessage.value = 'Authenticator may already be registered.'; @@ -268,42 +260,63 @@ async function handleRegister() } -// Handle deleting a passkey async function handleDelete(credentialID) { if (!credentialID) return; - // Optional: Add a confirmation dialog here - // if (!confirm('Are you sure you want to delete this passkey?')) { - // return; - // } + if (passkeys.value.length <= 1) + { + 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 - deleteErrorMessage.value = ''; - deleteSuccessMessage.value = ''; - registerSuccessMessage.value = ''; // Clear other messages - registerErrorMessage.value = ''; - identifyErrorMessage.value = ''; - identifiedPasskeyId.value = null; + $q.dialog({ + title: 'Confirm Deletion', + message: 'Are you sure you want to delete this passkey? This action cannot be undone.', + cancel: true, + persistent: true, + ok: { + 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 - { - await axios.delete(`/api/auth/passkeys/${credentialID}`); - deleteSuccessMessage.value = 'Passkey deleted successfully.'; - fetchPasskeys(); // Refresh the list - } - catch (error) - { - console.error('Error deleting passkey:', error); - deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.'; - } - finally - { - deleteLoading.value = null; // Clear loading state - } + try + { + await axios.delete(`/api/auth/passkeys/${credentialID}`); + deleteSuccessMessage.value = 'Passkey deleted successfully.'; + fetchPasskeys(); + } + catch (error) + { + console.error('Error deleting passkey:', error); + deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.'; + } + finally + { + deleteLoading.value = null; + } + }); } -// Handle identifying a passkey async function handleIdentify() { if (!isLoggedIn.value) @@ -314,8 +327,7 @@ async function handleIdentify() identifyLoading.value = true; identifyErrorMessage.value = ''; - identifiedPasskeyId.value = null; // Reset identified key - // Clear other messages + identifiedPasskeyId.value = null; registerSuccessMessage.value = ''; registerErrorMessage.value = ''; deleteSuccessMessage.value = ''; @@ -323,30 +335,21 @@ async function handleIdentify() try { - // 1. Get authentication options from the server - // We don't need to send username as the server should use the session - const optionsRes = await axios.post('/api/auth/generate-authentication-options', {}); // Send empty body + const optionsRes = await axios.post('/api/auth/generate-authentication-options', {}); const options = optionsRes.data; - // Optionally filter options to only allow the specific key if needed, but usually not necessary for identification - // options.allowCredentials = options.allowCredentials?.filter(cred => cred.id === credentialIDToIdentify); - - // 2. Start authentication ceremony in the browser const authResp = await startAuthentication(options); - // 3. If successful, the response contains the ID of the key used identifiedPasskeyId.value = authResp.id; console.log('Identified Passkey ID:', identifiedPasskeyId.value); - // Optional: Add a small delay before clearing the highlight setTimeout(() => { - // Only clear if it's still the same identified key if (identifiedPasskeyId.value === authResp.id) { identifiedPasskeyId.value = null; } - }, 5000); // Clear highlight after 5 seconds + }, 5000); } catch (error) @@ -364,7 +367,7 @@ async function handleIdentify() } finally { - identifyLoading.value = null; // Clear loading state + identifyLoading.value = null; } } diff --git a/src/pages/RegisterPage.vue b/src/pages/RegisterPage.vue index 9de3477..0f0aacc 100644 --- a/src/pages/RegisterPage.vue +++ b/src/pages/RegisterPage.vue @@ -2,9 +2,9 @@ - + - {{ isLoggedIn ? 'Register New Passkey' : 'Register Passkey' }} + Register Passkey @@ -13,21 +13,35 @@ v-model="username" label="Username" outlined - dense class="q-mb-md" :rules="[val => !!val || 'Username is required']" @keyup.enter="handleRegister" - :disable="isLoggedIn" - :hint="isLoggedIn ? 'Registering a new passkey for your current account.' : ''" - :readonly="isLoggedIn" + /> + + + - + - - diff --git a/src/pages/UserPreferencesPage.vue b/src/pages/UserPreferencesPage.vue new file mode 100644 index 0000000..1d9f2b0 --- /dev/null +++ b/src/pages/UserPreferencesPage.vue @@ -0,0 +1,119 @@ + + + + + + User Preferences + + + + + Loading preferences... + + + + + + + {{ error }} + + + + + + + + + + {{ groupName.replaceAll('_', ' ') }} + + + + + + + + + + + + + + + + + diff --git a/src/router/routes.js b/src/router/routes.js index 5df8272..fb67072 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -85,6 +85,14 @@ const routes = [ title: 'Settings', caption: 'Manage application settings' } + }, + { + path: 'user-preferences', + name: 'userPreferences', + component: () => import('pages/UserPreferencesPage.vue'), + meta: { + requiresAuth: true + } } ] }, diff --git a/src/stores/preferences.js b/src/stores/preferences.js new file mode 100644 index 0000000..1e16532 --- /dev/null +++ b/src/stores/preferences.js @@ -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, + }; +});