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 { query, pool } = require('./db')
|
||||||
const authMiddleware = require('./middleware/auth');
|
const authMiddleware = require('./middleware/auth');
|
||||||
const adminAuthMiddleware = require('./middleware/adminAuth');
|
const adminAuthMiddleware = require('./middleware/adminAuth');
|
||||||
|
const settingsAdminRoutes = require('./routes/settingsAdmin');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
// routes
|
// routes
|
||||||
|
|
@ -111,6 +112,7 @@ if (!fs.existsSync(path.join(__dirname, '../public/uploads'))) {
|
||||||
if (!fs.existsSync(path.join(__dirname, '../public/uploads/products'))) {
|
if (!fs.existsSync(path.join(__dirname, '../public/uploads/products'))) {
|
||||||
fs.mkdirSync(path.join(__dirname, '../public/uploads/products'), { recursive: true });
|
fs.mkdirSync(path.join(__dirname, '../public/uploads/products'), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// For direct access to product images
|
// For direct access to product images
|
||||||
app.use('/products/images', express.static(path.join(__dirname, '../public/uploads/products')));
|
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')));
|
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
|
// Use routes
|
||||||
|
app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||||
app.use('/api/products', productRoutes(pool, query));
|
app.use('/api/products', productRoutes(pool, query));
|
||||||
app.use('/api/auth', authRoutes(pool, query));
|
app.use('/api/auth', authRoutes(pool, query));
|
||||||
app.use('/api/cart', cartRoutes(pool, query, authMiddleware(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 AdminCategoriesPage = lazy(() => import('./pages/Admin/CategoriesPage'));
|
||||||
const AdminCustomersPage = lazy(() => import('./pages/Admin/CustomersPage'));
|
const AdminCustomersPage = lazy(() => import('./pages/Admin/CustomersPage'));
|
||||||
const AdminOrdersPage = lazy(() => import('./pages/Admin/OrdersPage'));
|
const AdminOrdersPage = lazy(() => import('./pages/Admin/OrdersPage'));
|
||||||
|
const AdminSettingsPage = lazy(() => import('./pages/Admin/SettingsPage'));
|
||||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
||||||
|
|
||||||
// Loading component for suspense fallback
|
// Loading component for suspense fallback
|
||||||
const LoadingComponent = () => (
|
const LoadingComponent = () => (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
|
@ -76,6 +76,7 @@ function App() {
|
||||||
<Route path="products/new" element={<AdminProductEditPage />} />
|
<Route path="products/new" element={<AdminProductEditPage />} />
|
||||||
<Route path="categories" element={<AdminCategoriesPage />} />
|
<Route path="categories" element={<AdminCategoriesPage />} />
|
||||||
<Route path="customers" element={<AdminCustomersPage />} />
|
<Route path="customers" element={<AdminCustomersPage />} />
|
||||||
|
<Route path="settings" element={<AdminSettingsPage />} />
|
||||||
<Route path="orders" element={<AdminOrdersPage />} />
|
<Route path="orders" element={<AdminOrdersPage />} />
|
||||||
</Route>
|
</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 DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||||
import PeopleIcon from '@mui/icons-material/People';
|
import PeopleIcon from '@mui/icons-material/People';
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||||
import CategoryIcon from '@mui/icons-material/Category';
|
import CategoryIcon from '@mui/icons-material/Category';
|
||||||
import HomeIcon from '@mui/icons-material/Home';
|
import HomeIcon from '@mui/icons-material/Home';
|
||||||
|
|
@ -63,6 +64,7 @@ const AdminLayout = () => {
|
||||||
{ text: 'Categories', icon: <ClassIcon />, path: '/admin/categories' },
|
{ text: 'Categories', icon: <ClassIcon />, path: '/admin/categories' },
|
||||||
{ text: 'Orders', icon: <ShoppingCartIcon />, path: '/admin/orders' },
|
{ text: 'Orders', icon: <ShoppingCartIcon />, path: '/admin/orders' },
|
||||||
{ text: 'Customers', icon: <PeopleIcon />, path: '/admin/customers' },
|
{ text: 'Customers', icon: <PeopleIcon />, path: '/admin/customers' },
|
||||||
|
{ text: 'Settings', icon: <SettingsIcon />, path: '/admin/settings' },
|
||||||
{ text: 'Reports', icon: <BarChartIcon />, path: '/admin/reports' },
|
{ 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