From 58764c222419fbe2b3059586aa913059fdc6a49c Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Fri, 25 Apr 2025 23:59:29 -0500 Subject: [PATCH] settings now editable in admin panel --- backend/src/index.js | 3 + backend/src/models/SystemSettings.js | 181 +++++++ backend/src/routes/settingsAdmin.js | 311 ++++++++++++ db/init/09-system-settings.sql | 45 ++ frontend/src/App.jsx | 3 +- frontend/src/hooks/settingsAdminHooks.js | 103 ++++ frontend/src/layouts/AdminLayout.jsx | 2 + frontend/src/pages/Admin/SettingsPage.jsx | 450 ++++++++++++++++++ frontend/src/services/settingsAdminService.js | 90 ++++ 9 files changed, 1187 insertions(+), 1 deletion(-) create mode 100644 backend/src/models/SystemSettings.js create mode 100644 backend/src/routes/settingsAdmin.js create mode 100644 db/init/09-system-settings.sql create mode 100644 frontend/src/hooks/settingsAdminHooks.js create mode 100644 frontend/src/pages/Admin/SettingsPage.jsx create mode 100644 frontend/src/services/settingsAdminService.js diff --git a/backend/src/index.js b/backend/src/index.js index 540e58d..5e61b44 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -7,6 +7,7 @@ const config = require('./config'); const { query, pool } = require('./db') const authMiddleware = require('./middleware/auth'); const adminAuthMiddleware = require('./middleware/adminAuth'); +const settingsAdminRoutes = require('./routes/settingsAdmin'); const fs = require('fs'); // routes @@ -111,6 +112,7 @@ if (!fs.existsSync(path.join(__dirname, '../public/uploads'))) { if (!fs.existsSync(path.join(__dirname, '../public/uploads/products'))) { fs.mkdirSync(path.join(__dirname, '../public/uploads/products'), { recursive: true }); } + // For direct access to product images app.use('/products/images', express.static(path.join(__dirname, '../public/uploads/products'))); app.use('/api/products/images', express.static(path.join(__dirname, '../public/uploads/products'))); @@ -219,6 +221,7 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re }); // Use routes +app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/products', productRoutes(pool, query)); app.use('/api/auth', authRoutes(pool, query)); app.use('/api/cart', cartRoutes(pool, query, authMiddleware(pool, query))); diff --git a/backend/src/models/SystemSettings.js b/backend/src/models/SystemSettings.js new file mode 100644 index 0000000..2a84242 --- /dev/null +++ b/backend/src/models/SystemSettings.js @@ -0,0 +1,181 @@ +/** + * System settings model + * This module handles database operations for system settings + */ + +/** + * Get all system settings + * @param {Object} db - Database connection pool + * @param {Function} query - Query function + * @returns {Promise} - Array of settings + */ +const getAllSettings = async (db, query) => { + try { + const result = await query( + 'SELECT * FROM system_settings ORDER BY category, key' + ); + return result.rows; + } catch (error) { + console.error('Error retrieving system settings:', error); + throw error; + } + }; + + /** + * Get settings by category + * @param {Object} db - Database connection pool + * @param {Function} query - Query function + * @param {string} category - Settings category + * @returns {Promise} - Array of settings in the category + */ + const getSettingsByCategory = async (db, query, category) => { + try { + const result = await query( + 'SELECT * FROM system_settings WHERE category = $1 ORDER BY key', + [category] + ); + return result.rows; + } catch (error) { + console.error(`Error retrieving ${category} settings:`, error); + throw error; + } + }; + + /** + * Get a specific setting + * @param {Object} db - Database connection pool + * @param {Function} query - Query function + * @param {string} key - Setting key + * @returns {Promise} - Setting value + */ + const getSetting = async (db, query, key) => { + try { + const result = await query( + 'SELECT * FROM system_settings WHERE key = $1', + [key] + ); + return result.rows[0]; + } catch (error) { + console.error(`Error retrieving setting ${key}:`, error); + throw error; + } + }; + + /** + * Update a setting + * @param {Object} db - Database connection pool + * @param {Function} query - Query function + * @param {string} key - Setting key + * @param {string} value - Setting value + * @param {string} category - Setting category + * @returns {Promise} - Updated setting + */ + const updateSetting = async (db, query, key, value, category) => { + try { + // Check if setting exists + const existing = await query( + 'SELECT * FROM system_settings WHERE key = $1', + [key] + ); + + if (existing.rows.length === 0) { + // Create new setting + const result = await query( + 'INSERT INTO system_settings (key, value, category) VALUES ($1, $2, $3) RETURNING *', + [key, value, category] + ); + return result.rows[0]; + } else { + // Update existing setting + const result = await query( + 'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2 RETURNING *', + [value, key] + ); + return result.rows[0]; + } + } catch (error) { + console.error(`Error updating setting ${key}:`, error); + throw error; + } + }; + + /** + * Update multiple settings at once + * @param {Object} db - Database connection pool + * @param {Function} query - Query function + * @param {Array} settings - Array of settings objects {key, value, category} + * @returns {Promise} - Array of updated settings + */ + const updateSettings = async (db, query, settings) => { + const client = await db.connect(); + try { + await client.query('BEGIN'); + + const updatedSettings = []; + + for (const setting of settings) { + const { key, value, category } = setting; + + // Check if setting exists + const existing = await client.query( + 'SELECT * FROM system_settings WHERE key = $1', + [key] + ); + + let result; + if (existing.rows.length === 0) { + // Create new setting + result = await client.query( + 'INSERT INTO system_settings (key, value, category) VALUES ($1, $2, $3) RETURNING *', + [key, value, category] + ); + } else { + // Update existing setting + result = await client.query( + 'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2 RETURNING *', + [value, key] + ); + } + + updatedSettings.push(result.rows[0]); + } + + await client.query('COMMIT'); + return updatedSettings; + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error updating settings:', error); + throw error; + } finally { + client.release(); + } + }; + + /** + * Delete a setting + * @param {Object} db - Database connection pool + * @param {Function} query - Query function + * @param {string} key - Setting key + * @returns {Promise} - Success status + */ + const deleteSetting = async (db, query, key) => { + try { + await query( + 'DELETE FROM system_settings WHERE key = $1', + [key] + ); + return true; + } catch (error) { + console.error(`Error deleting setting ${key}:`, error); + throw error; + } + }; + + module.exports = { + getAllSettings, + getSettingsByCategory, + getSetting, + updateSetting, + updateSettings, + deleteSetting + }; \ No newline at end of file diff --git a/backend/src/routes/settingsAdmin.js b/backend/src/routes/settingsAdmin.js new file mode 100644 index 0000000..4e9d937 --- /dev/null +++ b/backend/src/routes/settingsAdmin.js @@ -0,0 +1,311 @@ +const express = require('express'); +const fs = require('fs').promises; +const path = require('path'); +const router = express.Router(); +const SystemSettings = require('../models/SystemSettings'); +const config = require('../config'); + +module.exports = (pool, query, authMiddleware) => { + // Apply authentication middleware to all routes + router.use(authMiddleware); + + /** + * Get all settings + */ + router.get('/', async (req, res, next) => { + try { + // Check if user is admin + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + const settings = await SystemSettings.getAllSettings(pool, query); + + // Group settings by category + const groupedSettings = settings.reduce((acc, setting) => { + if (!acc[setting.category]) { + acc[setting.category] = []; + } + acc[setting.category].push(setting); + return acc; + }, {}); + + res.json(groupedSettings); + } catch (error) { + next(error); + } + }); + + /** + * Get settings by category + */ + router.get('/category/:category', async (req, res, next) => { + try { + const { category } = req.params; + + // Check if user is admin + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + const settings = await SystemSettings.getSettingsByCategory(pool, query, category); + res.json(settings); + } catch (error) { + next(error); + } + }); + + /** + * Get a specific setting + */ + router.get('/:key', async (req, res, next) => { + try { + const { key } = req.params; + + // Check if user is admin + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + const setting = await SystemSettings.getSetting(pool, query, key); + + if (!setting) { + return res.status(404).json({ + error: true, + message: `Setting with key "${key}" not found` + }); + } + + res.json(setting); + } catch (error) { + next(error); + } + }); + + /** + * Update a single setting + */ + router.put('/:key', async (req, res, next) => { + try { + const { key } = req.params; + const { value, category } = req.body; + + // Check if user is admin + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + if (value === undefined || !category) { + return res.status(400).json({ + error: true, + message: 'Value and category are required' + }); + } + + const updatedSetting = await SystemSettings.updateSetting(pool, query, key, value, category); + + // Update config in memory + updateConfigInMemory(updatedSetting); + + // Update environment file + await updateEnvironmentFile(); + + res.json({ + message: 'Setting updated successfully', + setting: updatedSetting + }); + } catch (error) { + next(error); + } + }); + + /** + * Update multiple settings at once + */ + router.post('/batch', async (req, res, next) => { + try { + const { settings } = req.body; + + // Check if user is admin + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + if (!Array.isArray(settings) || settings.length === 0) { + return res.status(400).json({ + error: true, + message: 'Settings array is required' + }); + } + + // Validate all settings have required fields + for (const setting of settings) { + if (!setting.key || setting.value === undefined || !setting.category) { + return res.status(400).json({ + error: true, + message: 'Each setting must have key, value, and category fields' + }); + } + } + + const updatedSettings = await SystemSettings.updateSettings(pool, query, settings); + + // Update config in memory for each setting + updatedSettings.forEach(setting => { + updateConfigInMemory(setting); + }); + + // Update environment file + await updateEnvironmentFile(); + + res.json({ + message: 'Settings updated successfully', + settings: updatedSettings + }); + } catch (error) { + next(error); + } + }); + + /** + * Delete a setting + */ + router.delete('/:key', async (req, res, next) => { + try { + const { key } = req.params; + + // Check if user is admin + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + await SystemSettings.deleteSetting(pool, query, key); + + // Update environment file + await updateEnvironmentFile(); + + res.json({ + message: `Setting "${key}" deleted successfully` + }); + } catch (error) { + next(error); + } + }); + + /** + * Helper function to update config in memory + */ + function updateConfigInMemory(setting) { + const { key, value, category } = setting; + + // Map database settings to config structure + if (category === 'email') { + if (key === 'smtp_host') config.email.host = value; + if (key === 'smtp_port') config.email.port = parseInt(value, 10); + if (key === 'smtp_user') config.email.user = value; + if (key === 'smtp_password') config.email.pass = value; + if (key === 'smtp_from_email') config.email.reply = value; + } else if (category === 'site') { + if (key === 'site_name') config.site.name = value; + if (key === 'site_domain') config.site.domain = value; + if (key === 'site_api_domain') config.site.apiDomain = value; + if (key === 'site_protocol') config.site.protocol = value; + if (key === 'site_environment') config.environment = value; + } + + // You can add more mappings for other categories here + } + + /** + * Helper function to update environment file + */ + async function updateEnvironmentFile() { + try { + // Get all settings from database + const allSettings = await SystemSettings.getAllSettings(pool, query); + + // Build environment variables string + let envContent = ''; + + // Add standard environment variables + envContent += `PORT=${process.env.PORT || config.port || 4000}\n`; + envContent += `NODE_ENV=${process.env.NODE_ENV || 'development'}\n\n`; + + // Add database configuration - use existing config values as fallbacks + envContent += `# Database connection\n`; + envContent += `DB_HOST=${config.db.host}\n`; + envContent += `DB_USER=${config.db.user}\n`; + envContent += `DB_PASSWORD=${config.db.password}\n`; + envContent += `DB_NAME=${config.db.database}\n`; + envContent += `DB_PORT=${config.db.port}\n`; + + // Get site environment from settings or use existing config + const siteEnvSetting = allSettings.find(s => s.key === 'site_environment'); + const currentEnvironment = siteEnvSetting?.value || config.environment || process.env.ENVIRONMENT || 'beta'; + envContent += `ENVIRONMENT=${currentEnvironment}\n\n`; + + // Add email configuration with fallbacks to existing config + envContent += `# Email configuration\n`; + const emailSettings = allSettings.filter(s => s.category === 'email'); + + const smtpHost = emailSettings.find(s => s.key === 'smtp_host'); + const smtpPort = emailSettings.find(s => s.key === 'smtp_port'); + const smtpUser = emailSettings.find(s => s.key === 'smtp_user'); + const smtpPass = emailSettings.find(s => s.key === 'smtp_password'); + const smtpReply = emailSettings.find(s => s.key === 'smtp_from_email'); + + // Use existing config values as fallbacks in this order: database setting → config value → env value → default + envContent += `EMAIL_HOST=${smtpHost?.value || config.email.host || process.env.EMAIL_HOST || 'smtp.postmarkapp.com'}\n`; + envContent += `EMAIL_PORT=${smtpPort?.value || config.email.port || process.env.EMAIL_PORT || '587'}\n`; + envContent += `EMAIL_USER=${smtpUser?.value || config.email.user || process.env.EMAIL_USER || ''}\n`; + envContent += `EMAIL_PASS=${smtpPass?.value || config.email.pass || process.env.EMAIL_PASS || ''}\n`; + envContent += `EMAIL_REPLY=${smtpReply?.value || config.email.reply || process.env.EMAIL_REPLY || 'noreply@2many.ca'}\n\n`; + + // Add payment configuration with fallbacks to existing values + const paymentSettings = allSettings.filter(s => s.category === 'payment'); + if (paymentSettings.length > 0 || process.env.STRIPE_PUBLIC_KEY || process.env.STRIPE_SECRET_KEY) { + envContent += `# Payment configuration\n`; + const stripePublic = paymentSettings.find(s => s.key === 'stripe_public_key'); + const stripeSecret = paymentSettings.find(s => s.key === 'stripe_secret_key'); + + // Include payment settings if they exist in either DB or environment + if (stripePublic?.value || process.env.STRIPE_PUBLIC_KEY) { + envContent += `STRIPE_PUBLIC_KEY=${stripePublic?.value || process.env.STRIPE_PUBLIC_KEY}\n`; + } + if (stripeSecret?.value || process.env.STRIPE_SECRET_KEY) { + envContent += `STRIPE_SECRET_KEY=${stripeSecret?.value || process.env.STRIPE_SECRET_KEY}\n`; + } + } + + // Write to .env file + const envPath = path.join(__dirname, '../../.env'); + await fs.writeFile(envPath, envContent); + + console.log('Environment file updated successfully'); + return true; + } catch (error) { + console.error('Error updating environment file:', error); + throw error; + } + } + + return router; +}; \ No newline at end of file diff --git a/db/init/09-system-settings.sql b/db/init/09-system-settings.sql new file mode 100644 index 0000000..e672d33 --- /dev/null +++ b/db/init/09-system-settings.sql @@ -0,0 +1,45 @@ +-- Create system_settings table for storing application configuration +CREATE TABLE IF NOT EXISTS system_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(255) NOT NULL UNIQUE, + value TEXT, + category VARCHAR(100) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create index on key for faster lookups +CREATE INDEX IF NOT EXISTS idx_system_settings_key ON system_settings(key); + +-- Create index on category for filtering +CREATE INDEX IF NOT EXISTS idx_system_settings_category ON system_settings(category); + +-- Insert default settings +INSERT INTO system_settings (key, value, category) +VALUES + -- SMTP Settings + ('smtp_host', NULL, 'email'), + ('smtp_port', NULL, 'email'), + ('smtp_user', NULL, 'email'), + ('smtp_password', NULL, 'email'), + ('smtp_from_email', NULL, 'email'), + ('smtp_from_name', NULL, 'email'), + + -- Site Settings + ('site_name', NULL, 'site'), + ('site_domain', NULL, 'site'), + ('site_api_domain', NULL, 'site'), + ('site_protocol', NULL, 'site'), + ('site_environment', NULL, 'site'), + + -- Payment Settings + ('stripe_public_key', NULL, 'payment'), + ('stripe_secret_key', NULL, 'payment'), + ('currency', 'CAD', 'payment'), + ('tax_rate', '0', 'payment'), + + -- Shipping Settings + ('shipping_flat_rate', '10.00', 'shipping'), + ('shipping_free_threshold', '50.00', 'shipping'), + ('shipping_enabled', 'true', 'shipping') +ON CONFLICT (key) DO NOTHING; \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d130246..c82ca31 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -24,8 +24,8 @@ const AdminProductEditPage = lazy(() => import('./pages/Admin/ProductEditPage')) const AdminCategoriesPage = lazy(() => import('./pages/Admin/CategoriesPage')); const AdminCustomersPage = lazy(() => import('./pages/Admin/CustomersPage')); const AdminOrdersPage = lazy(() => import('./pages/Admin/OrdersPage')); +const AdminSettingsPage = lazy(() => import('./pages/Admin/SettingsPage')); const NotFoundPage = lazy(() => import('./pages/NotFoundPage')); - // Loading component for suspense fallback const LoadingComponent = () => ( @@ -76,6 +76,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/hooks/settingsAdminHooks.js b/frontend/src/hooks/settingsAdminHooks.js new file mode 100644 index 0000000..cf859fe --- /dev/null +++ b/frontend/src/hooks/settingsAdminHooks.js @@ -0,0 +1,103 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { settingsAdminService } from '../services/settingsAdminService'; +import { useNotification } from './reduxHooks'; + +/** + * Hook for fetching all settings + */ +export const useAdminSettings = () => { + return useQuery({ + queryKey: ['admin-settings'], + queryFn: settingsAdminService.getAllSettings + }); +}; + +/** + * Hook for fetching settings by category + * @param {string} category - Category name + */ +export const useAdminSettingsByCategory = (category) => { + return useQuery({ + queryKey: ['admin-settings', category], + queryFn: () => settingsAdminService.getSettingsByCategory(category), + enabled: !!category + }); +}; + +/** + * Hook for fetching a single setting + * @param {string} key - Setting key + */ +export const useAdminSetting = (key) => { + return useQuery({ + queryKey: ['admin-setting', key], + queryFn: () => settingsAdminService.getSetting(key), + enabled: !!key + }); +}; + +/** + * Hook for updating a single setting + */ +export const useUpdateSetting = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: ({ key, value, category }) => settingsAdminService.updateSetting(key, value, category), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + notification.showNotification('Setting updated successfully', 'success'); + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to update setting', + 'error' + ); + } + }); +}; + +/** + * Hook for updating multiple settings at once + */ +export const useUpdateSettings = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: (settings) => settingsAdminService.updateSettings(settings), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + notification.showNotification('Settings updated successfully', 'success'); + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to update settings', + 'error' + ); + } + }); +}; + +/** + * Hook for deleting a setting + */ +export const useDeleteSetting = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: (key) => settingsAdminService.deleteSetting(key), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + notification.showNotification('Setting deleted successfully', 'success'); + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to delete setting', + 'error' + ); + } + }); +}; \ No newline at end of file diff --git a/frontend/src/layouts/AdminLayout.jsx b/frontend/src/layouts/AdminLayout.jsx index 40cf27a..fd15fa2 100644 --- a/frontend/src/layouts/AdminLayout.jsx +++ b/frontend/src/layouts/AdminLayout.jsx @@ -9,6 +9,7 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import DashboardIcon from '@mui/icons-material/Dashboard'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import PeopleIcon from '@mui/icons-material/People'; +import SettingsIcon from '@mui/icons-material/Settings'; import BarChartIcon from '@mui/icons-material/BarChart'; import CategoryIcon from '@mui/icons-material/Category'; import HomeIcon from '@mui/icons-material/Home'; @@ -63,6 +64,7 @@ const AdminLayout = () => { { text: 'Categories', icon: , path: '/admin/categories' }, { text: 'Orders', icon: , path: '/admin/orders' }, { text: 'Customers', icon: , path: '/admin/customers' }, + { text: 'Settings', icon: , path: '/admin/settings' }, { text: 'Reports', icon: , path: '/admin/reports' }, ]; diff --git a/frontend/src/pages/Admin/SettingsPage.jsx b/frontend/src/pages/Admin/SettingsPage.jsx new file mode 100644 index 0000000..5587463 --- /dev/null +++ b/frontend/src/pages/Admin/SettingsPage.jsx @@ -0,0 +1,450 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Tab, + Tabs, + Paper, + Grid, + TextField, + FormControlLabel, + Switch, + Button, + CircularProgress, + Alert, + Divider, + IconButton, + Tooltip, + InputAdornment, + Card, + CardContent, + CardHeader, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + MenuItem, + Select, + FormControl, + InputLabel +} from '@mui/material'; +import { + Save as SaveIcon, + Refresh as RefreshIcon, + Visibility as VisibilityIcon, + VisibilityOff as VisibilityOffIcon, + Add as AddIcon, + Delete as DeleteIcon +} from '@mui/icons-material'; +import { useAdminSettings, useUpdateSettings, useUpdateSetting, useDeleteSetting } from '../../hooks/settingsAdminHooks'; + +function TabPanel({ children, value, index, ...other }) { + return ( + + ); +} + +const AdminSettingsPage = () => { + const [activeTab, setActiveTab] = useState(0); + const [formData, setFormData] = useState({}); + const [showPasswords, setShowPasswords] = useState({}); + const [newSetting, setNewSetting] = useState({ + key: '', + value: '', + category: '' + }); + const [showNewSettingForm, setShowNewSettingForm] = useState(false); + const [settingToDelete, setSettingToDelete] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + // Fetch settings + const { data: settingsData, isLoading, error, refetch } = useAdminSettings(); + + // Update settings mutation + const updateSettings = useUpdateSettings(); + const updateSetting = useUpdateSetting(); + const deleteSetting = useDeleteSetting(); + + // Handle tab change + const handleTabChange = (event, newValue) => { + setActiveTab(newValue); + }; + + // Initialize form data when settings are loaded + React.useEffect(() => { + if (settingsData) { + const initialData = {}; + Object.keys(settingsData).forEach(category => { + settingsData[category].forEach(setting => { + initialData[setting.key] = setting.value; + }); + }); + setFormData(initialData); + } + }, [settingsData]); + + // Handle form input changes + const handleChange = (e) => { + const { name, value, checked, type } = e.target; + setFormData({ + ...formData, + [name]: type === 'checkbox' ? checked.toString() : value + }); + }; + + // Toggle password visibility + const togglePasswordVisibility = (key) => { + setShowPasswords({ + ...showPasswords, + [key]: !showPasswords[key] + }); + }; + + // Handle new setting form changes + const handleNewSettingChange = (e) => { + const { name, value } = e.target; + setNewSetting({ + ...newSetting, + [name]: value + }); + }; + + // Add new setting + const handleAddNewSetting = async () => { + if (!newSetting.key || !newSetting.category) { + return; + } + + try { + await updateSetting.mutateAsync({ + key: newSetting.key, + value: newSetting.value, + category: newSetting.category + }); + + setNewSetting({ + key: '', + value: '', + category: '' + }); + setShowNewSettingForm(false); + + // Refresh settings data + refetch(); + } catch (error) { + console.error('Failed to add new setting:', error); + } + }; + + // Open delete setting dialog + const handleOpenDeleteDialog = (setting) => { + setSettingToDelete(setting); + setDeleteDialogOpen(true); + }; + + // Confirm delete setting + const handleConfirmDelete = async () => { + if (settingToDelete) { + try { + await deleteSetting.mutateAsync(settingToDelete.key); + setDeleteDialogOpen(false); + setSettingToDelete(null); + + // Refresh settings data + refetch(); + } catch (error) { + console.error('Failed to delete setting:', error); + } + } + }; + + // Save all settings + const handleSaveSettings = async () => { + const settingsToUpdate = []; + + if (settingsData) { + // Prepare settings array for batch update + Object.keys(settingsData).forEach(category => { + settingsData[category].forEach(setting => { + if (formData[setting.key] !== setting.value) { + settingsToUpdate.push({ + key: setting.key, + value: formData[setting.key], + category: setting.category + }); + } + }); + }); + + if (settingsToUpdate.length > 0) { + try { + await updateSettings.mutateAsync(settingsToUpdate); + } catch (error) { + console.error('Failed to update settings:', error); + } + } + } + }; + + // Loading state + if (isLoading) { + return ( + + + + ); + } + + // Error state + if (error) { + return ( + + Error loading settings: {error.message} + + ); + } + + // Get category names for tabs + const categories = settingsData ? Object.keys(settingsData) : []; + + return ( + + + System Settings + + + + + + {categories.map((category, index) => ( + + ))} + + + + {categories.map((category, index) => ( + + + + {category.charAt(0).toUpperCase() + category.slice(1)} Settings + + + + + + {settingsData[category].map((setting) => ( + + + + + + {setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + + + handleOpenDeleteDialog(setting)} + > + + + + + + {setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('key') ? ( + + togglePasswordVisibility(setting.key)} + edge="end" + > + {showPasswords[setting.key] ? : } + + + ) + }} + /> + ) : setting.key.includes('enabled') || setting.key.includes('active') ? ( + + } + label={formData[setting.key] === 'true' ? 'Enabled' : 'Disabled'} + /> + ) : ( + + )} + + + Last updated: {new Date(setting.updated_at).toLocaleString()} + + + + + ))} + + + ))} + + + + + + + + {/* New Setting Dialog */} + setShowNewSettingForm(false)} maxWidth="sm" fullWidth> + Add New Setting + + + + Category + + + + {newSetting.category === 'new' && ( + setNewSetting({...newSetting, customCategory: e.target.value})} + /> + )} + + + + 50} + rows={newSetting.key.includes('description') ? 4 : 1} + /> + + + + + + + + + {/* Delete Setting Dialog */} + setDeleteDialogOpen(false)}> + Confirm Deletion + + + Are you sure you want to delete the setting "{settingToDelete?.key}"? + This action cannot be undone. + + + + + + + + + ); +}; + +export default AdminSettingsPage; \ No newline at end of file diff --git a/frontend/src/services/settingsAdminService.js b/frontend/src/services/settingsAdminService.js new file mode 100644 index 0000000..8279b40 --- /dev/null +++ b/frontend/src/services/settingsAdminService.js @@ -0,0 +1,90 @@ +import apiClient from './api'; + +export const settingsAdminService = { + /** + * Get all settings grouped by category + * @returns {Promise} Promise with the API response + */ + getAllSettings: async () => { + try { + const response = await apiClient.get('/admin/settings'); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + }, + + /** + * Get settings by category + * @param {string} category - Category name + * @returns {Promise} Promise with the API response + */ + getSettingsByCategory: async (category) => { + try { + const response = await apiClient.get(`/admin/settings/category/${category}`); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + }, + + /** + * Get a specific setting + * @param {string} key - Setting key + * @returns {Promise} Promise with the API response + */ + getSetting: async (key) => { + try { + const response = await apiClient.get(`/admin/settings/${key}`); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + }, + + /** + * Update a single setting + * @param {string} key - Setting key + * @param {string} value - Setting value + * @param {string} category - Setting category + * @returns {Promise} Promise with the API response + */ + updateSetting: async (key, value, category) => { + try { + const response = await apiClient.put(`/admin/settings/${key}`, { value, category }); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + }, + + /** + * Update multiple settings at once + * @param {Array} settings - Array of setting objects {key, value, category} + * @returns {Promise} Promise with the API response + */ + updateSettings: async (settings) => { + try { + const response = await apiClient.post('/admin/settings/batch', { settings }); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + }, + + /** + * Delete a setting + * @param {string} key - Setting key + * @returns {Promise} Promise with the API response + */ + deleteSetting: async (key) => { + try { + const response = await apiClient.delete(`/admin/settings/${key}`); + return response.data; + } catch (error) { + throw error.response?.data || { message: 'An unknown error occurred' }; + } + } +}; + +export default settingsAdminService; \ No newline at end of file