settings now editable in admin panel

This commit is contained in:
2ManyProjects 2025-04-25 23:59:29 -05:00
parent a67b4f881f
commit 58764c2224
9 changed files with 1187 additions and 1 deletions

View file

@ -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)));

View 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
};

View 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;
};

View 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;

View file

@ -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>

View 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'
);
}
});
};

View file

@ -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' },
];

View 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;

View 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;