diff --git a/backend/src/middleware/adminAuth.js b/backend/src/middleware/adminAuth.js index 2527697..aa2c172 100644 --- a/backend/src/middleware/adminAuth.js +++ b/backend/src/middleware/adminAuth.js @@ -15,8 +15,24 @@ module.exports = (pool, query) => { try { // Verify API key and check admin status + // const result = await query( + // 'SELECT id, email, first_name, last_name, is_admin FROM users WHERE api_key = $1', + // [apiKey] + // ); const result = await query( - 'SELECT id, email, first_name, last_name, is_admin FROM users WHERE api_key = $1', + `SELECT + u.id, + u.email, + u.first_name, + u.last_name, + u.is_admin, + CASE WHEN s.user_id IS NOT NULL THEN TRUE ELSE FALSE END AS is_super_admin + FROM + users u + LEFT JOIN + superadmins s ON u.id = s.user_id + WHERE + u.api_key = $1`, [apiKey] ); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index a7cfc73..017baab 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -42,6 +42,10 @@ module.exports = (pool, query) => { 'UPDATE users SET is_admin = TRUE WHERE email = $1', [email] ); + await query( + 'INSERT INTO superadmins (user_id) values ($1) RETURNING id, created_at', + [result.rows[0].id] + ); } await emailService.sendWelcomeEmail({ @@ -205,15 +209,33 @@ module.exports = (pool, query) => { ); // Get user information including admin status + // const userInfo = await query( + // 'SELECT id, email, first_name, last_name, is_admin FROM users WHERE id = $1', + // [userId] + // ); const userInfo = await query( - 'SELECT id, email, first_name, last_name, is_admin FROM users WHERE id = $1', + `SELECT + u.id, + u.email, + u.first_name, + u.last_name, + u.is_admin, + CASE WHEN s.user_id IS NOT NULL THEN TRUE ELSE FALSE END AS is_super_admin + FROM + users u + LEFT JOIN + superadmins s ON u.id = s.user_id + WHERE + u.id = $1`, [userId] ); + res.json({ message: 'Login successful', userId: userId, isAdmin: userInfo.rows[0].is_admin, + isSuperAdmin: userInfo.rows[0].is_super_admin, firstName: userInfo.rows[0].first_name, lastName: userInfo.rows[0].last_name, email: userInfo.rows[0].email, diff --git a/backend/src/routes/settingsAdmin.js b/backend/src/routes/settingsAdmin.js index 2fe68e3..23e8277 100644 --- a/backend/src/routes/settingsAdmin.js +++ b/backend/src/routes/settingsAdmin.js @@ -113,7 +113,14 @@ module.exports = (pool, query, authMiddleware) => { message: 'Value and category are required' }); } + const setting = await SystemSettings.getSetting(pool, query, key) + if(setting?.super_req && !req.user.is_super_admin){ + return res.status(400).json({ + error: true, + message: `Super Admin access required to modify ${key}` + }); + } const updatedSetting = await SystemSettings.updateSetting(pool, query, key, value, category); // Update config in memory @@ -137,7 +144,7 @@ module.exports = (pool, query, authMiddleware) => { router.post('/batch', async (req, res, next) => { try { const { settings } = req.body; - + console.log(req.user) if (!req.user.is_admin) { return res.status(403).json({ @@ -152,7 +159,7 @@ module.exports = (pool, query, authMiddleware) => { message: 'Settings array is required' }); } - + const categorySettings = await SystemSettings.getSettingsByCategory(pool, query, settings[0]?.category) // Validate all settings have required fields for (const setting of settings) { if (!setting.key || setting.value === undefined || !setting.category) { @@ -161,6 +168,12 @@ module.exports = (pool, query, authMiddleware) => { message: 'Each setting must have key, value, and category fields' }); } + if(categorySettings.find(item => item.key === setting.key)?.super_req && !req.user.is_super_admin){ + return res.status(400).json({ + error: true, + message: `Super Admin access required to modify ${setting.key}` + }); + } } const updatedSettings = await SystemSettings.updateSettings(pool, query, settings); diff --git a/db/init/09-system-settings.sql b/db/init/09-system-settings.sql index a0e2b1e..e0224f0 100644 --- a/db/init/09-system-settings.sql +++ b/db/init/09-system-settings.sql @@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS system_settings ( key VARCHAR(255) NOT NULL UNIQUE, value TEXT, category VARCHAR(100) NOT NULL, + super_req BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); @@ -19,51 +20,50 @@ CREATE INDEX IF NOT EXISTS idx_system_settings_category ON system_settings(categ 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'), + ('smtp_host', NULL, 'email', TRUE), + ('smtp_port', NULL, 'email', TRUE), + ('smtp_user', NULL, 'email', TRUE), + ('smtp_password', NULL, 'email', TRUE), + ('smtp_from_email', NULL, 'email', TRUE), + ('smtp_from_name', NULL, 'email', FALSE), -- Site Settings - -- ('site_name', NULL, 'site'), - ('site_domain', NULL, 'site'), - ('site_api_domain', NULL, 'site'), - ('site_protocol', NULL, 'site'), - ('site_environment', NULL, 'site'), - ('site_deployment', NULL, 'site'), - ('site_redis_host', NULL, 'site'), - ('site_redis_tls', NULL, 'site'), - ('site_aws_region', NULL, 'site'), - ('site_aws_s3_bucket', NULL, 'site'), - ('site_cdn_domain', NULL, 'site'), - ('site_aws_queue_url', NULL, 'site'), - ('site_read_host', NULL, 'site'), - ('site_db_max_connections', NULL, 'site'), - ('site_session_secret', NULL, 'site'), - ('site_redis_port', NULL, 'site'), - ('site_redis_password', NULL, 'site'), + ('site_domain', NULL, 'site', TRUE), + ('site_api_domain', NULL, 'site', TRUE), + ('site_protocol', NULL, 'site', TRUE), + ('site_environment', NULL, 'site', TRUE), + ('site_deployment', NULL, 'site', TRUE), + ('site_redis_host', NULL, 'site', TRUE), + ('site_redis_tls', NULL, 'site', TRUE), + ('site_aws_region', NULL, 'site', TRUE), + ('site_aws_s3_bucket', NULL, 'site', TRUE), + ('site_cdn_domain', NULL, 'site', TRUE), + ('site_aws_queue_url', NULL, 'site', TRUE), + ('site_read_host', NULL, 'site', TRUE), + ('site_db_max_connections', NULL, 'site', TRUE), + ('site_session_secret', NULL, 'site', TRUE), + ('site_redis_port', NULL, 'site', TRUE), + ('site_redis_password', NULL, 'site', TRUE), -- Payment Settings - ('currency', 'CAD', 'payment'), - ('tax_rate', '0', 'payment'), + ('currency', 'CAD', 'payment', FALSE), + ('tax_rate', '0', 'payment', FALSE), -- Shipping Settings - ('shipping_flat_rate', '10.00', 'shipping'), - ('shipping_free_threshold', '50.00', 'shipping'), - ('shipping_enabled', 'true', 'shipping'), - ('easypost_api_key', NULL, 'shipping'), - ('easypost_enabled', 'false', 'shipping'), - ('shipping_origin_street', '123 Main St', 'shipping'), - ('shipping_origin_city', 'Vancouver', 'shipping'), - ('shipping_origin_state', 'BC', 'shipping'), - ('shipping_origin_zip', 'V6K 1V6', 'shipping'), - ('shipping_origin_country', 'CA', 'shipping'), - ('shipping_default_package_length', '15', 'shipping'), - ('shipping_default_package_width', '12', 'shipping'), - ('shipping_default_package_height', '10', 'shipping'), - ('shipping_default_package_unit', 'cm', 'shipping'), - ('shipping_default_weight_unit', 'g', 'shipping'), - ('shipping_carriers_allowed', 'USPS,UPS,FedEx,DHL,Canada Post,Purolator', 'shipping') + ('shipping_flat_rate', '10.00', 'shipping', FALSE), + ('shipping_free_threshold', '50.00', 'shipping', FALSE), + ('shipping_enabled', 'true', 'shipping', FALSE), + ('easypost_api_key', NULL, 'shipping', TRUE), + ('easypost_enabled', 'false', 'shipping', FALSE), + ('shipping_origin_street', '123 Main St', 'shipping', FALSE), + ('shipping_origin_city', 'Vancouver', 'shipping', FALSE), + ('shipping_origin_state', 'BC', 'shipping', FALSE), + ('shipping_origin_zip', 'V6K 1V6', 'shipping', FALSE), + ('shipping_origin_country', 'CA', 'shipping', FALSE), + ('shipping_default_package_length', '15', 'shipping', FALSE), + ('shipping_default_package_width', '12', 'shipping', FALSE), + ('shipping_default_package_height', '10', 'shipping', FALSE), + ('shipping_default_package_unit', 'cm', 'shipping', FALSE), + ('shipping_default_package_weight_unit', 'g', 'shipping', FALSE), + ('shipping_carriers_allowed', 'USPS,UPS,FedEx,DHL,Canada Post,Purolator', 'shipping', FALSE) ON CONFLICT (key) DO NOTHING; \ No newline at end of file diff --git a/db/init/23-suparadmin.sql b/db/init/23-suparadmin.sql new file mode 100644 index 0000000..be674e1 --- /dev/null +++ b/db/init/23-suparadmin.sql @@ -0,0 +1,9 @@ +-- Create superadmins table that links to the users table +CREATE TABLE superadmins ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + description TEXT +); + +-- Create index for faster lookups +CREATE INDEX idx_superadmins_user_id ON superadmins(user_id); diff --git a/frontend/src/components/CouponInput.jsx b/frontend/src/components/CouponInput.jsx index d286ea1..c13eec2 100644 --- a/frontend/src/components/CouponInput.jsx +++ b/frontend/src/components/CouponInput.jsx @@ -11,8 +11,8 @@ import { Chip } from '@mui/material'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import couponService from '../services/couponService'; -import { useAuth } from '../hooks/reduxHooks'; +import couponService from '@services/couponService'; +import { useAuth } from '@hooks/reduxHooks'; /** * Component for inputting and applying coupon codes to the cart diff --git a/frontend/src/features/auth/authSlice.js b/frontend/src/features/auth/authSlice.js index fc58ff5..f8d1fec 100644 --- a/frontend/src/features/auth/authSlice.js +++ b/frontend/src/features/auth/authSlice.js @@ -28,17 +28,21 @@ export const authSlice = createSlice({ email: action.payload.email, firstName: action.payload.firstName, lastName: action.payload.lastName, - isAdmin: action.payload.isAdmin + isAdmin: action.payload.isAdmin, + isSuperAdmin: action.payload.isSuperAdmin }; state.apiKey = action.payload.apiKey; state.isAdmin = action.payload.isAdmin; + state.isSuperAdmin = action.payload.isSuperAdmin; localStorage.setItem('apiKey', action.payload.apiKey); localStorage.setItem('isAdmin', action.payload.isAdmin); + localStorage.setItem('isSuperAdmin', action.payload.isSuperAdmin); localStorage.setItem('user', JSON.stringify(action.payload.user)); localStorage.setItem('userData', JSON.stringify({ id: action.payload.user, apiKey: action.payload.apiKey, - isAdmin: action.payload.isAdmin + isAdmin: action.payload.isAdmin, + isSuperAdmin: action.payload.isSuperAdmin })); }, loginFailed: (state, action) => { @@ -51,8 +55,10 @@ export const authSlice = createSlice({ state.userData = null; state.apiKey = null; state.isAdmin = false; + state.isSuperAdmin = false; localStorage.removeItem('apiKey'); localStorage.removeItem('isAdmin'); + localStorage.removeItem('isSuperAdmin'); localStorage.removeItem('user'); localStorage.removeItem('userData'); }, @@ -67,6 +73,7 @@ export const { loginStart, loginSuccess, loginFailed, logout, clearError } = aut // Selectors export const selectIsAuthenticated = (state) => state.auth.isAuthenticated; export const selectIsAdmin = (state) => state.auth.isAdmin; +export const selectIsSuperAdmin = (state) => state.auth.isSuperAdmin; export const selectCurrentUser = (state) => state.auth.user; export const selectApiKey = (state) => state.auth.apiKey; export const selectAuthLoading = (state) => state.auth.loading; diff --git a/frontend/src/hooks/apiHooks.js b/frontend/src/hooks/apiHooks.js index 9b1ae49..c5aa700 100644 --- a/frontend/src/hooks/apiHooks.js +++ b/frontend/src/hooks/apiHooks.js @@ -144,7 +144,7 @@ export const useVerifyCode = () => { return useMutation({ mutationFn: (verifyData) => authService.verifyCode(verifyData), onSuccess: (data) => { - login(data.userId, data.apiKey, data.isAdmin, data.email, data?.firstName, data?.lastName); + login(data.userId, data.apiKey, data.isAdmin, data.isSuperAdmin, data.email, data?.firstName, data?.lastName); notification.showNotification('Login successful', 'success'); Clarity.identify(data.userId, data.apiKey); }, diff --git a/frontend/src/hooks/reduxHooks.js b/frontend/src/hooks/reduxHooks.js index dcbdb19..74cc235 100644 --- a/frontend/src/hooks/reduxHooks.js +++ b/frontend/src/hooks/reduxHooks.js @@ -57,6 +57,7 @@ export const useDarkMode = () => { export const useAuth = () => { const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated); const isAdmin = useAppSelector((state) => state.auth.isAdmin); + const isSuperAdmin = useAppSelector((state) => state.auth.isSuperAdmin); const user = useAppSelector((state) => state.auth.user); const userData = useAppSelector((state) => state.auth.userData); const apiKey = useAppSelector((state) => state.auth.apiKey); @@ -67,15 +68,16 @@ export const useAuth = () => { return { isAuthenticated, isAdmin, + isSuperAdmin, user, userData, apiKey, loading, error, - login: (user, apiKey, isAdmin, email, firstName = "", lastName = "") => + login: (user, apiKey, isAdmin, isSuperAdmin, email, firstName = "", lastName = "") => dispatch({ type: 'auth/loginSuccess', - payload: { user, apiKey, isAdmin, email, firstName, lastName}}), + payload: { user, apiKey, isAdmin, isSuperAdmin, email, firstName, lastName}}), logout: () => dispatch({ type: 'auth/logout' }), clearError: () => dispatch({ type: 'auth/clearError' }), }; diff --git a/frontend/src/pages/Admin/SettingsPage.jsx b/frontend/src/pages/Admin/SettingsPage.jsx index db37bb6..e2e0451 100644 --- a/frontend/src/pages/Admin/SettingsPage.jsx +++ b/frontend/src/pages/Admin/SettingsPage.jsx @@ -36,7 +36,9 @@ import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; -import { useAdminSettings, useUpdateSettings, useUpdateSetting, useDeleteSetting } from '../../hooks/settingsAdminHooks'; +import { useAdminSettings, useUpdateSettings, useUpdateSetting, useDeleteSetting } from '@hooks/settingsAdminHooks'; +import { useAuth } from '@hooks/reduxHooks'; + function TabPanel({ children, value, index, ...other }) { return ( @@ -69,9 +71,9 @@ const AdminSettingsPage = () => { const [settingToDelete, setSettingToDelete] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const { isSuperAdmin } = useAuth(); // Fetch settings const { data: settingsData, isLoading, error, refetch } = useAdminSettings(); - // Update settings mutation const updateSettings = useUpdateSettings(); const updateSetting = useUpdateSetting(); @@ -372,11 +374,11 @@ const AdminSettingsPage = () => { - {setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('key') ? ( { { fullWidth name={setting.key} label="Value" + disabled={setting.super_req ? !isSuperAdmin : false} value={formData[setting.key] || ''} onChange={handleChange} variant="outlined" diff --git a/frontend/src/services/consentService.js b/frontend/src/services/consentService.js index abed8e8..e8b0386 100644 --- a/frontend/src/services/consentService.js +++ b/frontend/src/services/consentService.js @@ -59,10 +59,10 @@ export const applyConsentSettings = (level) => { if (allowAnalytics) { // Enable Clarity tracking - Clarity.consent(); + Clarity?.consent(); } else { // Disable Clarity tracking - Clarity.consent({ clarity: false }); + Clarity?.consent({ clarity: false }); } console.log(`Applied consent level: ${level}, analytics ${allowAnalytics ? 'enabled' : 'disabled'}`);