Overhaul settings and implement user preferences. Also implements dark theme toggle as part of the user settings.
This commit is contained in:
parent
b84f0907a8
commit
727746030c
17 changed files with 760 additions and 378 deletions
|
@ -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
|
||||||
|
|
|
@ -4,4 +4,16 @@ import { PrismaClient } from '@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;
|
||||||
|
|
||||||
|
// 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}` });
|
||||||
|
};
|
|
@ -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;
|
|
@ -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' });
|
||||||
});
|
});
|
||||||
|
|
51
src-server/routes/settings.js
Normal file
51
src-server/routes/settings.js
Normal 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;
|
57
src-server/routes/userPreferences.js
Normal file
57
src-server/routes/userPreferences.js
Normal 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;
|
|
@ -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());
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) }
|
||||||
});
|
});
|
||||||
}
|
}
|
18
src/App.vue
18
src/App.vue
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
119
src/pages/UserPreferencesPage.vue
Normal file
119
src/pages/UserPreferencesPage.vue
Normal 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>
|
|
@ -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
115
src/stores/preferences.js
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue