diff --git a/src/App.js b/src/App.js index ed23527..7023eb9 100644 --- a/src/App.js +++ b/src/App.js @@ -22,6 +22,7 @@ import AdminUserView from './pages/admin/AdminUserView'; import AdminStaff from './pages/admin/AdminStaff'; import AdminMembers from './pages/admin/AdminMembers'; import AdminPermissions from './pages/admin/AdminPermissions'; +import AdminSettings from './pages/admin/AdminSettings'; import AdminRoles from './pages/admin/AdminRoles'; import AdminEvents from './pages/admin/AdminEvents'; import AdminEventAttendance from './pages/admin/AdminEventAttendance'; @@ -290,6 +291,13 @@ function App() { } /> + + + + + + } /> {/* 404 - Catch all undefined routes */} } /> diff --git a/src/components/AdminSidebar.js b/src/components/AdminSidebar.js index 182fb06..26c8702 100644 --- a/src/components/AdminSidebar.js +++ b/src/components/AdminSidebar.js @@ -175,17 +175,28 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { path: '/admin/permissions', disabled: false, superadminOnly: true + }, + { + name: 'Settings', + icon: Settings, + path: '/admin/settings', + disabled: false, + superadminOnly: true } ]; // Filter nav items based on user role const filteredNavItems = navItems.filter(item => { if (item.superadminOnly && user?.role !== 'superadmin') { + console.log('Filtering out superadmin-only item:', item.name, 'User role:', user?.role); return false; } return true; }); + // Debug: Log filtered items count + console.log('Total nav items:', navItems.length, 'Filtered items:', filteredNavItems.length, 'User role:', user?.role); + const isActive = (path) => { if (path === '/admin') { return location.pathname === '/admin'; @@ -364,11 +375,21 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { {renderNavItem(filteredNavItems.find(item => item.name === 'Bylaws'))} - {/* Permissions - Superadmin only (no header) */} + {/* SYSTEM Section - Superadmin only */} {user?.role === 'superadmin' && ( -
- {renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))} -
+ <> + {isOpen && ( +
+

+ System +

+
+ )} +
+ {renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))} + {renderNavItem(filteredNavItems.find(item => item.name === 'Settings'))} +
+ )} diff --git a/src/components/ChangeRoleDialog.js b/src/components/ChangeRoleDialog.js new file mode 100644 index 0000000..dafa8d3 --- /dev/null +++ b/src/components/ChangeRoleDialog.js @@ -0,0 +1,149 @@ +import React, { useState, useEffect } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; +import { Button } from './ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Label } from './ui/label'; +import { AlertCircle, Shield } from 'lucide-react'; +import api from '../utils/api'; +import { toast } from 'sonner'; + +export default function ChangeRoleDialog({ open, onClose, user, onSuccess }) { + const [roles, setRoles] = useState([]); + const [selectedRole, setSelectedRole] = useState(''); + const [selectedRoleId, setSelectedRoleId] = useState(null); + const [loadingRoles, setLoadingRoles] = useState(false); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (open) { + fetchRoles(); + // Pre-select current role + setSelectedRole(user.role); + setSelectedRoleId(user.role_id); + } + }, [open, user]); + + const fetchRoles = async () => { + setLoadingRoles(true); + try { + // Reuse existing endpoint that returns assignable roles based on privilege + const response = await api.get('/admin/roles/assignable'); + // Map API response to format expected by Select component + const mappedRoles = response.data.map(role => ({ + value: role.code, + label: role.name, + id: role.id, + description: role.description + })); + setRoles(mappedRoles); + } catch (error) { + console.error('Failed to fetch assignable roles:', error); + toast.error('Failed to load roles. Please try again.'); + } finally { + setLoadingRoles(false); + } + }; + + const handleSubmit = async () => { + if (!selectedRole) { + toast.error('Please select a role'); + return; + } + + // Don't submit if role hasn't changed + if (selectedRole === user.role && selectedRoleId === user.role_id) { + toast.info('The selected role is the same as current role'); + return; + } + + setSubmitting(true); + try { + await api.put(`/admin/users/${user.id}/role`, { + role: selectedRole, + role_id: selectedRoleId + }); + + toast.success(`Role changed to ${selectedRole}`); + + onSuccess(); + onClose(); + } catch (error) { + const message = error.response?.data?.detail || 'Failed to change role'; + toast.error(message); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + + Change User Role + + + Change role for {user.first_name} {user.last_name} ({user.email}) + + + +
+ {/* Current Role Display */} +
+

Current Role

+

{user.role}

+
+ + {/* Role Selection */} +
+ + +
+ + {/* Warning for privileged roles */} + {(selectedRole === 'admin' || selectedRole === 'superadmin') && ( +
+ +
+

Admin Access Warning

+

+ This user will gain full administrative access to the system. +

+
+
+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/pages/admin/AdminSettings.js b/src/pages/admin/AdminSettings.js new file mode 100644 index 0000000..6967086 --- /dev/null +++ b/src/pages/admin/AdminSettings.js @@ -0,0 +1,486 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Input } from '../../components/ui/input'; +import { Label } from '../../components/ui/label'; +import { AlertCircle, CheckCircle, Settings as SettingsIcon, RefreshCw, Zap, Edit, Save, X, Copy, Eye, EyeOff, ExternalLink } from 'lucide-react'; +import api from '../../utils/api'; +import { toast } from 'sonner'; + +export default function AdminSettings() { + const [stripeStatus, setStripeStatus] = useState(null); + const [loadingStatus, setLoadingStatus] = useState(true); + const [testing, setTesting] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [saving, setSaving] = useState(false); + + // Form state + const [formData, setFormData] = useState({ + secret_key: '', + webhook_secret: '' + }); + + // Show/hide sensitive values + const [showSecretKey, setShowSecretKey] = useState(false); + const [showWebhookSecret, setShowWebhookSecret] = useState(false); + + useEffect(() => { + fetchStripeStatus(); + }, []); + + const fetchStripeStatus = async () => { + setLoadingStatus(true); + try { + const response = await api.get('/admin/settings/stripe/status'); + setStripeStatus(response.data); + } catch (error) { + console.error('Failed to fetch Stripe status:', error); + toast.error('Failed to load Stripe status'); + } finally { + setLoadingStatus(false); + } + }; + + const handleTestConnection = async () => { + setTesting(true); + try { + const response = await api.post('/admin/settings/stripe/test-connection'); + toast.success(response.data.message); + } catch (error) { + const message = error.response?.data?.detail || 'Connection test failed'; + toast.error(message); + } finally { + setTesting(false); + } + }; + + const handleEditClick = () => { + setIsEditing(true); + setFormData({ + secret_key: '', + webhook_secret: '' + }); + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setFormData({ + secret_key: '', + webhook_secret: '' + }); + setShowSecretKey(false); + setShowWebhookSecret(false); + }; + + const handleSave = async () => { + // Validate inputs + if (!formData.secret_key || !formData.webhook_secret) { + toast.error('Both Secret Key and Webhook Secret are required'); + return; + } + + if (!formData.secret_key.startsWith('sk_test_') && !formData.secret_key.startsWith('sk_live_')) { + toast.error('Invalid Secret Key format. Must start with sk_test_ or sk_live_'); + return; + } + + if (!formData.webhook_secret.startsWith('whsec_')) { + toast.error('Invalid Webhook Secret format. Must start with whsec_'); + return; + } + + setSaving(true); + try { + await api.put('/admin/settings/stripe', formData); + toast.success('Stripe settings updated successfully'); + setIsEditing(false); + setFormData({ + secret_key: '', + webhook_secret: '' + }); + setShowSecretKey(false); + setShowWebhookSecret(false); + // Refresh status + await fetchStripeStatus(); + } catch (error) { + const message = error.response?.data?.detail || 'Failed to update Stripe settings'; + toast.error(message); + } finally { + setSaving(false); + } + }; + + const copyToClipboard = (text, label) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied to clipboard`); + }; + + if (loadingStatus) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+

+ + Settings +

+

+ Manage system configuration and integrations +

+
+ + {/* Stripe Integration Card */} + + +
+
+ + + Stripe Integration + + + Payment processing and subscription management + +
+ {!isEditing && ( + + )} +
+
+ + + {isEditing ? ( + /* Edit Mode */ +
+ {/* Secret Key Input */} +
+ +
+ setFormData({ ...formData, secret_key: e.target.value })} + placeholder="sk_test_... or sk_live_..." + className="pr-10" + /> + +
+

+ Get this from your Stripe Dashboard → Developers → API keys +

+
+ + {/* Webhook Secret Input */} +
+ +
+ setFormData({ ...formData, webhook_secret: e.target.value })} + placeholder="whsec_..." + className="pr-10" + /> + +
+

+ Get this from your Stripe Dashboard → Developers → Webhooks → Add endpoint +

+
+ + {/* Action Buttons */} +
+ + +
+
+ ) : ( + /* View Mode */ + <> + {/* Status Display */} +
+
+
+

Configuration Status

+

Credentials stored in database (encrypted)

+
+
+ {stripeStatus?.configured ? ( + <> + + Configured + + ) : ( + <> + + Not Configured + + )} +
+
+ + {stripeStatus?.configured && ( + <> +
+
+

Environment

+

Detected from secret key prefix

+
+ + {stripeStatus.environment === 'live' ? 'Live' : 'Test'} + +
+ +
+
+

Secret Key

+

+ {stripeStatus.secret_key_prefix}... +

+
+ +
+ +
+
+

Webhook Secret

+

Webhook endpoint configuration

+
+ {stripeStatus.webhook_secret_set ? ( +
+ + Set +
+ ) : ( +
+ + Not Set +
+ )} +
+ + {/* Webhook URL */} +
+
+
+

+ + Webhook URL +

+

+ Configure this webhook endpoint in your Stripe Dashboard: +

+
+ {stripeStatus.webhook_url} +
+
+ +
+
+

Webhook Events:

+
+
+

✅ Currently Handled:

+
    +
  • checkout.session.completed - Subscription & donation payments
  • +
+
+
+

🔄 Coming Soon:

+
    +
  • invoice.payment_succeeded
  • +
  • invoice.payment_failed
  • +
  • customer.subscription.updated
  • +
  • customer.subscription.deleted
  • +
+
+
+
+
+ + )} +
+ + {/* Configuration Instructions (Not Configured) */} + {!stripeStatus?.configured && ( + <> +
+
+ +
+

Configuration Required

+

+ Click "Edit Settings" above to configure your Stripe credentials. +

+

+ Get your API keys from{' '} + + Stripe Dashboard + +

+
+
+
+ + {/* Webhook URL Info (Always visible) */} +
+
+
+

+ + Webhook URL Configuration +

+

+ After configuring your API keys, set up this webhook endpoint in your Stripe Dashboard: +

+
+ {stripeStatus?.webhook_url || 'http://localhost:8000/api/webhooks/stripe'} +
+
+ +
+
+

Webhook Events:

+
+
+

✅ Currently Handled:

+
    +
  • checkout.session.completed - Subscription & donation payments
  • +
+
+
+

🔄 Coming Soon:

+
    +
  • invoice.payment_succeeded
  • +
  • invoice.payment_failed
  • +
  • customer.subscription.updated
  • +
  • customer.subscription.deleted
  • +
+
+
+
+
+ + )} + + {/* Test Connection Button */} + {stripeStatus?.configured && ( +
+ + +
+ )} + + )} +
+
+ + {/* Future Settings Sections Placeholder */} +
+

Additional settings sections will be added here

+

(Email, Storage, Notifications, etc.)

+
+
+ ); +} diff --git a/src/pages/admin/AdminUserView.js b/src/pages/admin/AdminUserView.js index 8bc4d40..d94bd76 100644 --- a/src/pages/admin/AdminUserView.js +++ b/src/pages/admin/AdminUserView.js @@ -5,9 +5,10 @@ import { Card } from '../../components/ui/card'; import { Button } from '../../components/ui/button'; import { Badge } from '../../components/ui/badge'; import { Avatar, AvatarImage, AvatarFallback } from '../../components/ui/avatar'; -import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle, Camera, Upload, Trash2 } from 'lucide-react'; +import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle, Camera, Upload, Trash2, Shield } from 'lucide-react'; import { toast } from 'sonner'; import ConfirmationDialog from '../../components/ConfirmationDialog'; +import ChangeRoleDialog from '../../components/ChangeRoleDialog'; const AdminUserView = () => { const { userId } = useParams(); @@ -24,6 +25,7 @@ const AdminUserView = () => { const [maxFileSizeMB, setMaxFileSizeMB] = useState(50); const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800); const fileInputRef = useRef(null); + const [changeRoleDialogOpen, setChangeRoleDialogOpen] = useState(false); useEffect(() => { fetchConfig(); @@ -202,6 +204,11 @@ const AdminUserView = () => { return {}; }; + const handleRoleChanged = () => { + // Refresh user data after role change + fetchUserProfile(); + }; + if (loading) return
Loading...
; if (!user) return null; @@ -278,6 +285,15 @@ const AdminUserView = () => { {resetPasswordLoading ? 'Resetting...' : 'Reset Password'} + + {!user.email_verified && (