settings now editable in admin panel
This commit is contained in:
parent
a67b4f881f
commit
58764c2224
9 changed files with 1187 additions and 1 deletions
|
|
@ -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)));
|
||||
|
|
|
|||
181
backend/src/models/SystemSettings.js
Normal file
181
backend/src/models/SystemSettings.js
Normal file
|
|
@ -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>} - 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>} - 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<Object>} - 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<Object>} - 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>} - 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<boolean>} - 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
|
||||
};
|
||||
311
backend/src/routes/settingsAdmin.js
Normal file
311
backend/src/routes/settingsAdmin.js
Normal file
|
|
@ -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;
|
||||
};
|
||||
45
db/init/09-system-settings.sql
Normal file
45
db/init/09-system-settings.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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 = () => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
|
|
@ -76,6 +76,7 @@ function App() {
|
|||
<Route path="products/new" element={<AdminProductEditPage />} />
|
||||
<Route path="categories" element={<AdminCategoriesPage />} />
|
||||
<Route path="customers" element={<AdminCustomersPage />} />
|
||||
<Route path="settings" element={<AdminSettingsPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
103
frontend/src/hooks/settingsAdminHooks.js
Normal file
103
frontend/src/hooks/settingsAdminHooks.js
Normal file
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -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: <ClassIcon />, path: '/admin/categories' },
|
||||
{ text: 'Orders', icon: <ShoppingCartIcon />, path: '/admin/orders' },
|
||||
{ text: 'Customers', icon: <PeopleIcon />, path: '/admin/customers' },
|
||||
{ text: 'Settings', icon: <SettingsIcon />, path: '/admin/settings' },
|
||||
{ text: 'Reports', icon: <BarChartIcon />, path: '/admin/reports' },
|
||||
];
|
||||
|
||||
|
|
|
|||
450
frontend/src/pages/Admin/SettingsPage.jsx
Normal file
450
frontend/src/pages/Admin/SettingsPage.jsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`settings-tabpanel-${index}`}
|
||||
aria-labelledby={`settings-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading settings: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Get category names for tabs
|
||||
const categories = settingsData ? Object.keys(settingsData) : [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
System Settings
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ mb: 4 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
aria-label="settings tabs"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
<Tab key={category} label={category.charAt(0).toUpperCase() + category.slice(1)} id={`settings-tab-${index}`} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{categories.map((category, index) => (
|
||||
<TabPanel key={category} value={activeTab} index={index}>
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)} Settings
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => {
|
||||
setNewSetting({...newSetting, category});
|
||||
setShowNewSettingForm(true);
|
||||
}}
|
||||
>
|
||||
Add Setting
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{settingsData[category].map((setting) => (
|
||||
<Grid item xs={12} md={6} key={setting.key}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
{setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</Typography>
|
||||
<Tooltip title="Delete Setting">
|
||||
<IconButton
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={() => handleOpenDeleteDialog(setting)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('key') ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
name={setting.key}
|
||||
label="Value"
|
||||
type={showPasswords[setting.key] ? 'text' : 'password'}
|
||||
value={formData[setting.key] || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => togglePasswordVisibility(setting.key)}
|
||||
edge="end"
|
||||
>
|
||||
{showPasswords[setting.key] ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : setting.key.includes('enabled') || setting.key.includes('active') ? (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData[setting.key] === 'true'}
|
||||
onChange={handleChange}
|
||||
name={setting.key}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={formData[setting.key] === 'true' ? 'Enabled' : 'Disabled'}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
name={setting.key}
|
||||
label="Value"
|
||||
value={formData[setting.key] || ''}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Last updated: {new Date(setting.updated_at).toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
))}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', p: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={refetch}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSaveSettings}
|
||||
disabled={updateSettings.isLoading}
|
||||
>
|
||||
{updateSettings.isLoading ? <CircularProgress size={24} /> : 'Save Changes'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* New Setting Dialog */}
|
||||
<Dialog open={showNewSettingForm} onClose={() => setShowNewSettingForm(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Add New Setting</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel id="category-label">Category</InputLabel>
|
||||
<Select
|
||||
labelId="category-label"
|
||||
name="category"
|
||||
value={newSetting.category}
|
||||
onChange={handleNewSettingChange}
|
||||
label="Category"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<MenuItem key={category} value={category}>
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value="new">New Category...</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{newSetting.category === 'new' && (
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
name="category"
|
||||
label="New Category Name"
|
||||
value={newSetting.customCategory || ''}
|
||||
onChange={(e) => setNewSetting({...newSetting, customCategory: e.target.value})}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
name="key"
|
||||
label="Setting Key"
|
||||
value={newSetting.key}
|
||||
onChange={handleNewSettingChange}
|
||||
helperText="Use lowercase letters, numbers and underscores (e.g., stripe_public_key)"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
name="value"
|
||||
label="Setting Value"
|
||||
value={newSetting.value}
|
||||
onChange={handleNewSettingChange}
|
||||
multiline={newSetting.key.includes('description') || newSetting.value.length > 50}
|
||||
rows={newSetting.key.includes('description') ? 4 : 1}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowNewSettingForm(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleAddNewSetting}
|
||||
variant="contained"
|
||||
disabled={!newSetting.key || !(newSetting.category || newSetting.customCategory) || updateSetting.isLoading}
|
||||
>
|
||||
{updateSetting.isLoading ? <CircularProgress size={24} /> : 'Add Setting'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Setting Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Confirm Deletion</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete the setting "{settingToDelete?.key}"?
|
||||
This action cannot be undone.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleteSetting.isLoading}
|
||||
>
|
||||
{deleteSetting.isLoading ? <CircularProgress size={24} /> : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSettingsPage;
|
||||
90
frontend/src/services/settingsAdminService.js
Normal file
90
frontend/src/services/settingsAdminService.js
Normal file
|
|
@ -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;
|
||||
Loading…
Reference in a new issue