diff --git a/src/App.js b/src/App.js index bd2cf39..181053c 100644 --- a/src/App.js +++ b/src/App.js @@ -17,12 +17,13 @@ import BecomeMember from './pages/BecomeMember'; import PaymentSuccess from './pages/PaymentSuccess'; import PaymentCancel from './pages/PaymentCancel'; import AdminDashboard from './pages/admin/AdminDashboard'; -import AdminUsers from './pages/admin/AdminUsers'; 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 AdminRoles from './pages/admin/AdminRoles'; import AdminEvents from './pages/admin/AdminEvents'; -import AdminApprovals from './pages/admin/AdminApprovals'; +import AdminValidations from './pages/admin/AdminValidations'; import AdminPlans from './pages/admin/AdminPlans'; import AdminSubscriptions from './pages/admin/AdminSubscriptions'; import AdminLayout from './layouts/AdminLayout'; @@ -46,6 +47,8 @@ import Donate from './pages/Donate'; import DonationSuccess from './pages/DonationSuccess'; import Resources from './pages/Resources'; import ContactUs from './pages/ContactUs'; +import TermsOfService from './pages/TermsOfService'; +import PrivacyPolicy from './pages/PrivacyPolicy'; const PrivateRoute = ({ children, adminOnly = false }) => { const { user, loading } = useAuth(); @@ -58,7 +61,7 @@ const PrivateRoute = ({ children, adminOnly = false }) => { return ; } - if (adminOnly && user.role !== 'admin') { + if (adminOnly && !['admin', 'superadmin'].includes(user.role)) { return ; } @@ -105,6 +108,10 @@ function App() { } /> } /> + {/* Legal Pages - Public Access */} + } /> + } /> + @@ -189,13 +196,6 @@ function App() { } /> - - - - - - } /> @@ -210,10 +210,10 @@ function App() { } /> - - + } /> @@ -259,6 +259,13 @@ function App() { } /> + + + + + + } /> diff --git a/src/components/AdminSidebar.js b/src/components/AdminSidebar.js index 622579a..6c0578c 100644 --- a/src/components/AdminSidebar.js +++ b/src/components/AdminSidebar.js @@ -39,7 +39,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { try { const response = await api.get('/admin/users'); const pending = response.data.filter(u => - ['pending_approval', 'pre_approved'].includes(u.status) + ['pending_validation', 'pre_validated'].includes(u.status) ); setPendingCount(pending.length); } catch (error) { @@ -105,9 +105,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { disabled: false }, { - name: 'Approvals', + name: 'Validations', icon: CheckCircle, - path: '/admin/approvals', + path: '/admin/validations', disabled: false, badge: pendingCount }, @@ -154,9 +154,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { disabled: false }, { - name: 'Roles', + name: 'Permissions', icon: Shield, - path: '/admin/roles', + path: '/admin/permissions', disabled: false, superadminOnly: true } diff --git a/src/components/ConfirmationDialog.js b/src/components/ConfirmationDialog.js new file mode 100644 index 0000000..4b9fc2f --- /dev/null +++ b/src/components/ConfirmationDialog.js @@ -0,0 +1,111 @@ +import React from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from './ui/alert-dialog'; +import { AlertTriangle, Info, CheckCircle } from 'lucide-react'; + +/** + * Reusable confirmation dialog component following the design system + * + * @param {boolean} open - Controls dialog visibility + * @param {function} onOpenChange - Callback when dialog open state changes + * @param {function} onConfirm - Callback when user confirms + * @param {string} title - Dialog title + * @param {string} description - Dialog description/message + * @param {string} confirmText - Confirm button text (default: "Confirm") + * @param {string} cancelText - Cancel button text (default: "Cancel") + * @param {string} variant - Visual variant: 'warning', 'danger', 'info', 'success' (default: 'warning') + * @param {boolean} loading - Show loading state on confirm button + */ +const ConfirmationDialog = ({ + open, + onOpenChange, + onConfirm, + title, + description, + confirmText = 'Confirm', + cancelText = 'Cancel', + variant = 'warning', + loading = false, +}) => { + const variants = { + warning: { + icon: AlertTriangle, + iconColor: 'text-[#ff9e77]', + confirmButtonClass: 'bg-[#ff9e77] text-white hover:bg-[#e88d66] rounded-full px-6', + }, + danger: { + icon: AlertTriangle, + iconColor: 'text-red-600', + confirmButtonClass: 'bg-red-600 text-white hover:bg-red-700 rounded-full px-6', + }, + info: { + icon: Info, + iconColor: 'text-[#664fa3]', + confirmButtonClass: 'bg-[#664fa3] text-white hover:bg-[#553d8a] rounded-full px-6', + }, + success: { + icon: CheckCircle, + iconColor: 'text-[#81B29A]', + confirmButtonClass: 'bg-[#81B29A] text-white hover:bg-[#6fa188] rounded-full px-6', + }, + }; + + const config = variants[variant] || variants.warning; + const Icon = config.icon; + + return ( + + + +
+
+ +
+
+ + {title} + + + {description} + +
+
+
+ + + {cancelText} + + { + e.preventDefault(); + onConfirm(); + }} + className={config.confirmButtonClass} + disabled={loading} + > + {loading ? 'Processing...' : confirmText} + + +
+
+ ); +}; + +export default ConfirmationDialog; diff --git a/src/components/CreateMemberDialog.js b/src/components/CreateMemberDialog.js new file mode 100644 index 0000000..a2d11e5 --- /dev/null +++ b/src/components/CreateMemberDialog.js @@ -0,0 +1,336 @@ +import React, { useState } from 'react'; +import api from '../utils/api'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { toast } from 'sonner'; +import { Loader2, UserPlus } from 'lucide-react'; + +const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => { + const [formData, setFormData] = useState({ + email: '', + password: '', + first_name: '', + last_name: '', + phone: '', + address: '', + city: '', + state: '', + zipcode: '', + date_of_birth: '', + member_since: '', + role: 'member' + }); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState({}); + + const handleChange = (field, value) => { + setFormData(prev => ({ ...prev, [field]: value })); + // Clear error when user starts typing + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: null })); + } + }; + + const validate = () => { + const newErrors = {}; + + if (!formData.email) { + newErrors.email = 'Email is required'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = 'Invalid email format'; + } + + if (!formData.password || formData.password.length < 8) { + newErrors.password = 'Password must be at least 8 characters'; + } + + if (!formData.first_name) { + newErrors.first_name = 'First name is required'; + } + + if (!formData.last_name) { + newErrors.last_name = 'Last name is required'; + } + + if (!formData.phone) { + newErrors.phone = 'Phone is required'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!validate()) { + return; + } + + setLoading(true); + + try { + // Format dates for backend + const payload = { ...formData }; + if (payload.date_of_birth === '') { + delete payload.date_of_birth; + } + if (payload.member_since === '') { + delete payload.member_since; + } + + await api.post('/admin/users/create', payload); + toast.success('Member created successfully'); + + // Reset form + setFormData({ + email: '', + password: '', + first_name: '', + last_name: '', + phone: '', + address: '', + city: '', + state: '', + zipcode: '', + date_of_birth: '', + member_since: '', + role: 'member' + }); + + onOpenChange(false); + if (onSuccess) onSuccess(); + } catch (error) { + const errorMessage = error.response?.data?.detail || 'Failed to create member'; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + return ( + + + + + + Create Member + + + Create a new member account with direct login access. Member will be created immediately. + + + +
+
+ {/* Email & Password Row */} +
+
+ + handleChange('email', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="member@example.com" + /> + {errors.email && ( +

{errors.email}

+ )} +
+ +
+ + handleChange('password', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="Minimum 8 characters" + /> + {errors.password && ( +

{errors.password}

+ )} +
+
+ + {/* Name Row */} +
+
+ + handleChange('first_name', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="John" + /> + {errors.first_name && ( +

{errors.first_name}

+ )} +
+ +
+ + handleChange('last_name', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="Doe" + /> + {errors.last_name && ( +

{errors.last_name}

+ )} +
+
+ + {/* Phone */} +
+ + handleChange('phone', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="(555) 123-4567" + /> + {errors.phone && ( +

{errors.phone}

+ )} +
+ + {/* Address */} +
+ + handleChange('address', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="123 Main St" + /> +
+ + {/* City, State, Zipcode Row */} +
+
+ + handleChange('city', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="San Francisco" + /> +
+ +
+ + handleChange('state', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="CA" + maxLength={2} + /> +
+ +
+ + handleChange('zipcode', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="94102" + /> +
+
+ + {/* Dates Row */} +
+
+ + handleChange('date_of_birth', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + /> +
+ +
+ + handleChange('member_since', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + /> +
+
+
+ + + + + +
+
+
+ ); +}; + +export default CreateMemberDialog; diff --git a/src/components/CreateStaffDialog.js b/src/components/CreateStaffDialog.js new file mode 100644 index 0000000..314aa70 --- /dev/null +++ b/src/components/CreateStaffDialog.js @@ -0,0 +1,254 @@ +import React, { useState } from 'react'; +import api from '../utils/api'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { toast } from 'sonner'; +import { Loader2, UserPlus } from 'lucide-react'; + +const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => { + const [formData, setFormData] = useState({ + email: '', + password: '', + first_name: '', + last_name: '', + phone: '', + role: 'admin' + }); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState({}); + + const handleChange = (field, value) => { + setFormData(prev => ({ ...prev, [field]: value })); + // Clear error when user starts typing + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: null })); + } + }; + + const validate = () => { + const newErrors = {}; + + if (!formData.email) { + newErrors.email = 'Email is required'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = 'Invalid email format'; + } + + if (!formData.password || formData.password.length < 8) { + newErrors.password = 'Password must be at least 8 characters'; + } + + if (!formData.first_name) { + newErrors.first_name = 'First name is required'; + } + + if (!formData.last_name) { + newErrors.last_name = 'Last name is required'; + } + + if (!formData.phone) { + newErrors.phone = 'Phone is required'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!validate()) { + return; + } + + setLoading(true); + + try { + await api.post('/admin/users/create', formData); + toast.success('Staff member created successfully'); + + // Reset form + setFormData({ + email: '', + password: '', + first_name: '', + last_name: '', + phone: '', + role: 'admin' + }); + + onOpenChange(false); + if (onSuccess) onSuccess(); + } catch (error) { + const errorMessage = error.response?.data?.detail || 'Failed to create staff member'; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + return ( + + + + + + Create Staff Member + + + Create a new staff account with direct login access. User will be created immediately. + + + +
+
+ {/* Email */} +
+ + handleChange('email', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="staff@example.com" + /> + {errors.email && ( +

{errors.email}

+ )} +
+ + {/* Password */} +
+ + handleChange('password', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="Minimum 8 characters" + /> + {errors.password && ( +

{errors.password}

+ )} +
+ + {/* First Name */} +
+ + handleChange('first_name', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="John" + /> + {errors.first_name && ( +

{errors.first_name}

+ )} +
+ + {/* Last Name */} +
+ + handleChange('last_name', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="Doe" + /> + {errors.last_name && ( +

{errors.last_name}

+ )} +
+ + {/* Phone */} +
+ + handleChange('phone', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="(555) 123-4567" + /> + {errors.phone && ( +

{errors.phone}

+ )} +
+ + {/* Role */} +
+ + +
+
+ + + + + +
+
+
+ ); +}; + +export default CreateStaffDialog; diff --git a/src/components/ImportMembersDialog.js b/src/components/ImportMembersDialog.js new file mode 100644 index 0000000..5cca9de --- /dev/null +++ b/src/components/ImportMembersDialog.js @@ -0,0 +1,335 @@ +import React, { useState } from 'react'; +import api from '../utils/api'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Label } from './ui/label'; +import { Checkbox } from './ui/checkbox'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from './ui/table'; +import { Alert, AlertDescription } from './ui/alert'; +import { Badge } from './ui/badge'; +import { toast } from 'sonner'; +import { Loader2, Upload, FileUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; + +const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => { + const [file, setFile] = useState(null); + const [updateExisting, setUpdateExisting] = useState(false); + const [loading, setLoading] = useState(false); + const [dragActive, setDragActive] = useState(false); + const [importResult, setImportResult] = useState(null); + + const handleDrag = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true); + } else if (e.type === "dragleave") { + setDragActive(false); + } + }; + + const handleDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFileSelect(e.dataTransfer.files[0]); + } + }; + + const handleFileSelect = (selectedFile) => { + if (!selectedFile.name.endsWith('.csv')) { + toast.error('Please select a CSV file'); + return; + } + setFile(selectedFile); + setImportResult(null); + }; + + const handleFileInput = (e) => { + if (e.target.files && e.target.files[0]) { + handleFileSelect(e.target.files[0]); + } + }; + + const handleSubmit = async () => { + if (!file) { + toast.error('Please select a file'); + return; + } + + setLoading(true); + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('update_existing', updateExisting); + + const response = await api.post('/admin/users/import', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + setImportResult(response.data); + + if (response.data.status === 'completed') { + toast.success('All members imported successfully!'); + } else if (response.data.status === 'partial') { + toast.warning('Import partially successful. Check errors below.'); + } else { + toast.error('Import failed. Check errors below.'); + } + + if (onSuccess) onSuccess(); + } catch (error) { + const errorMessage = error.response?.data?.detail || 'Failed to import members'; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setFile(null); + setUpdateExisting(false); + setImportResult(null); + onOpenChange(false); + }; + + const getStatusIcon = (status) => { + if (status === 'completed') { + return ; + } else if (status === 'partial') { + return ; + } else { + return ; + } + }; + + const getStatusBadge = (status) => { + const config = { + completed: { label: 'Completed', className: 'bg-green-500 text-white' }, + partial: { label: 'Partial Success', className: 'bg-orange-500 text-white' }, + failed: { label: 'Failed', className: 'bg-red-500 text-white' }, + }; + + const statusConfig = config[status] || config.failed; + return ( + + {statusConfig.label} + + ); + }; + + return ( + + + + + + {importResult ? 'Import Results' : 'Import Members from CSV'} + + + {importResult + ? 'Review the import results below' + : 'Upload a CSV file to bulk import members. Ensure the CSV has the required columns.'} + + + + {!importResult ? ( + // Upload Form +
+ {/* CSV Format Instructions */} + + + Required columns: Email, First Name, Last Name, Phone, Role +
+ Optional columns: Status, Address, City, State, Zipcode, Date of Birth, Member Since +
+ Date format: YYYY-MM-DD (e.g., 2024-01-15) +
+
+ + {/* File Upload Area */} +
+ {file ? ( +
+ +
+

+ {file.name} +

+

+ {(file.size / 1024).toFixed(2)} KB +

+
+ +
+ ) : ( +
+ +
+

+ Drag and drop your CSV file here +

+

or

+ + +
+
+ )} +
+ + {/* Options */} +
+ + +
+
+ ) : ( + // Import Results +
+ {/* Summary Cards */} +
+
+

Total Rows

+

{importResult.total_rows}

+
+
+

Successful

+

{importResult.successful_rows}

+
+
+

Failed

+

{importResult.failed_rows}

+
+
+ {getStatusIcon(importResult.status)} + {getStatusBadge(importResult.status)} +
+
+ + {/* Errors Table */} + {importResult.errors && importResult.errors.length > 0 && ( +
+

+ Errors ({importResult.errors.length} {importResult.errors.length === 10 ? '- showing first 10' : ''}) +

+
+ + + + Row + Email + Error + + + + {importResult.errors.map((error, idx) => ( + + {error.row} + {error.email} + {error.error} + + ))} + +
+
+
+ )} +
+ )} + + + {!importResult ? ( + <> + + + + ) : ( + + )} + +
+
+ ); +}; + +export default ImportMembersDialog; diff --git a/src/components/InviteStaffDialog.js b/src/components/InviteStaffDialog.js new file mode 100644 index 0000000..b15d4ae --- /dev/null +++ b/src/components/InviteStaffDialog.js @@ -0,0 +1,313 @@ +import React, { useState, useEffect } from 'react'; +import api from '../utils/api'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { toast } from 'sonner'; +import { Loader2, Mail, Copy, Check } from 'lucide-react'; + +const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => { + const [formData, setFormData] = useState({ + email: '', + first_name: '', + last_name: '', + phone: '', + role: 'admin' + }); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState({}); + const [invitationUrl, setInvitationUrl] = useState(null); + const [copied, setCopied] = useState(false); + const [roles, setRoles] = useState([]); + const [loadingRoles, setLoadingRoles] = useState(false); + + // Fetch roles when dialog opens + useEffect(() => { + if (open) { + fetchRoles(); + } + }, [open]); + + const fetchRoles = async () => { + setLoadingRoles(true); + try { + const response = await api.get('/admin/roles'); + // Filter to show only admin-type roles (not guest or member) + const staffRoles = response.data.filter(role => + ['admin', 'superadmin', 'finance'].includes(role.code) || !role.is_system_role + ); + setRoles(staffRoles); + } catch (error) { + console.error('Failed to fetch roles:', error); + toast.error('Failed to load roles'); + } finally { + setLoadingRoles(false); + } + }; + + const handleChange = (field, value) => { + setFormData(prev => ({ ...prev, [field]: value })); + // Clear error when user starts typing + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: null })); + } + }; + + const validate = () => { + const newErrors = {}; + + if (!formData.email) { + newErrors.email = 'Email is required'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = 'Invalid email format'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!validate()) { + return; + } + + setLoading(true); + + try { + const response = await api.post('/admin/users/invite', formData); + toast.success('Invitation sent successfully'); + + // Show invitation URL + setInvitationUrl(response.data.invitation_url); + + // Don't close dialog yet - show invitation URL first + if (onSuccess) onSuccess(); + } catch (error) { + const errorMessage = error.response?.data?.detail || 'Failed to send invitation'; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + const copyToClipboard = () => { + navigator.clipboard.writeText(invitationUrl); + setCopied(true); + toast.success('Invitation link copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + }; + + const handleClose = () => { + // Reset form + setFormData({ + email: '', + first_name: '', + last_name: '', + phone: '', + role: 'admin' + }); + setInvitationUrl(null); + setCopied(false); + onOpenChange(false); + }; + + return ( + + + + + + {invitationUrl ? 'Invitation Sent' : 'Invite Staff Member'} + + + {invitationUrl + ? 'The invitation has been sent via email. You can also copy the link below.' + : 'Send an email invitation to join as staff. They will set their own password.'} + + + + {invitationUrl ? ( + // Show invitation URL after successful send +
+ +
+ + +
+
+ ) : ( + // Show invitation form +
+
+ {/* Email */} +
+ + handleChange('email', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="staff@example.com" + /> + {errors.email && ( +

{errors.email}

+ )} +
+ + {/* First Name (Optional) */} +
+ + handleChange('first_name', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="John" + /> +
+ + {/* Last Name (Optional) */} +
+ + handleChange('last_name', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="Doe" + /> +
+ + {/* Phone (Optional) */} +
+ + handleChange('phone', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="(555) 123-4567" + /> +
+ + {/* Role */} +
+ + + {roles.length > 0 && ( +

+ {roles.find(r => r.code === formData.role)?.description || ''} +

+ )} +
+
+ + + + + +
+ )} + + {invitationUrl && ( + + + + )} +
+
+ ); +}; + +export default InviteStaffDialog; diff --git a/src/components/Navbar.js b/src/components/Navbar.js index 4b700fc..95e50c4 100644 --- a/src/components/Navbar.js +++ b/src/components/Navbar.js @@ -32,7 +32,7 @@ const Navbar = () => { Welcome, {user.first_name} )} - {user?.role === 'admin' && ( + {(user?.role === 'admin' || user?.role === 'superadmin') && ( + + + ); + } + + return ( +
+ + {/* Header */} +
+
+
+ +
+
+

+ Welcome to LOAF! +

+

+ Complete your profile to accept the invitation +

+
+ + {/* Invitation Details */} +
+
+
+

+ Email Address +

+

+ {invitation?.email} +

+
+
+

+ Role +

+
{getRoleBadge(invitation?.role)}
+
+
+

+ + Invitation Expires +

+

+ {invitation?.expires_at ? new Date(invitation.expires_at).toLocaleString() : 'N/A'} +

+
+
+
+ + {/* Form */} +
+
+ {/* Password Fields */} +
+
+ + handleChange('password', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="Minimum 8 characters" + /> + {formErrors.password && ( +

{formErrors.password}

+ )} +
+ +
+ + handleChange('confirmPassword', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="Re-enter password" + /> + {formErrors.confirmPassword && ( +

{formErrors.confirmPassword}

+ )} +
+
+ + {/* Name Fields */} +
+
+ + handleChange('first_name', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="John" + /> + {formErrors.first_name && ( +

{formErrors.first_name}

+ )} +
+ +
+ + handleChange('last_name', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="Doe" + /> + {formErrors.last_name && ( +

{formErrors.last_name}

+ )} +
+
+ + {/* Phone */} +
+ + handleChange('phone', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="(555) 123-4567" + /> + {formErrors.phone && ( +

{formErrors.phone}

+ )} +
+ + {/* Optional Fields Section */} + {invitation?.role === 'member' && ( + <> +
+

+ Additional Information (Optional) +

+
+ + {/* Address */} +
+ + handleChange('address', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="123 Main St" + /> +
+ + {/* City, State, Zipcode */} +
+
+ + handleChange('city', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="San Francisco" + /> +
+ +
+ + handleChange('state', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="CA" + maxLength={2} + /> +
+ +
+ + handleChange('zipcode', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + placeholder="94102" + /> +
+
+ + {/* Date of Birth */} +
+ + handleChange('date_of_birth', e.target.value)} + className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" + /> +
+ + )} +
+ + {/* Submit Button */} +
+ +
+
+ + {/* Footer Note */} +
+

+ Already have an account?{' '} + +

+
+
+
+ ); +}; + +export default AcceptInvitation; diff --git a/src/pages/BecomeMember.js b/src/pages/BecomeMember.js index 8467263..38e4f27 100644 --- a/src/pages/BecomeMember.js +++ b/src/pages/BecomeMember.js @@ -111,7 +111,7 @@ const BecomeMember = () => { className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]" style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }} > - Complete the online application form and submit it. Check your email for a confirmation link and use it to verify your email. You will then begin to receive LOAF's monthly e-newsletter where all of the social events are listed. Your application will remain pending, and you won't be able to log into the Members Only section of the website until step 2 is complete and you are approved by an admin. + Complete the online application form and submit it. Check your email for a confirmation link and use it to verify your email. You will then begin to receive LOAF's monthly e-newsletter where all of the social events are listed. Your application will remain pending, and you won't be able to log into the Members Only section of the website until step 2 is complete and you are validated by an admin.

@@ -175,7 +175,7 @@ const BecomeMember = () => { className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]" style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }} > - Once we know that you are indeed you, an admin will approve your application and you will receive an email prompting you to login to your user profile and pay the annual administrative fee. + Once we know that you are indeed you, an admin will validate your application and you will receive an email prompting you to login to your user profile and pay the annual administrative fee.

diff --git a/src/pages/Dashboard.js b/src/pages/Dashboard.js index 3f203a0..55d4054 100644 --- a/src/pages/Dashboard.js +++ b/src/pages/Dashboard.js @@ -59,11 +59,14 @@ const Dashboard = () => { const getStatusBadge = (status) => { const statusConfig = { pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' }, - pending_approval: { icon: Clock, label: 'Pending Approval', className: 'bg-gray-200 text-gray-700' }, - pre_approved: { icon: CheckCircle, label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' }, + pending_validation: { icon: Clock, label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' }, + pre_validated: { icon: CheckCircle, label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' }, payment_pending: { icon: AlertCircle, label: 'Payment Pending', className: 'bg-orange-500 text-white' }, active: { icon: CheckCircle, label: 'Active', className: 'bg-[#81B29A] text-white' }, - inactive: { icon: AlertCircle, label: 'Inactive', className: 'bg-gray-400 text-white' } + inactive: { icon: AlertCircle, label: 'Inactive', className: 'bg-gray-400 text-white' }, + canceled: { icon: AlertCircle, label: 'Canceled', className: 'bg-red-100 text-red-700' }, + expired: { icon: Clock, label: 'Expired', className: 'bg-red-500 text-white' }, + abandoned: { icon: AlertCircle, label: 'Abandoned', className: 'bg-gray-300 text-gray-600' } }; const config = statusConfig[status] || statusConfig.inactive; @@ -80,11 +83,14 @@ const Dashboard = () => { const getStatusMessage = (status) => { const messages = { pending_email: 'Please check your email to verify your account.', - pending_approval: 'Your application is under review by our admin team.', - pre_approved: 'Your application is under review by our admin team.', + pending_validation: 'Your application is under review by our admin team.', + pre_validated: 'Your application is under review by our admin team.', payment_pending: 'Please complete your payment to activate your membership.', active: 'Your membership is active! Enjoy all member benefits.', - inactive: 'Your membership is currently inactive.' + inactive: 'Your membership is currently inactive.', + canceled: 'Your membership has been canceled. Contact us to rejoin.', + expired: 'Your membership has expired. Please renew to regain access.', + abandoned: 'Your application was not completed. Contact us to restart the process.' }; return messages[status] || ''; @@ -254,14 +260,14 @@ const Dashboard = () => { {/* CTA Section */} - {user?.status === 'pending_approval' && ( + {user?.status === 'pending_validation' && (

Application Under Review

- Your membership application is being reviewed by our admin team. You'll be notified once approved! + Your membership application is being reviewed by our admin team. You'll be notified once validated!

@@ -278,7 +284,7 @@ const Dashboard = () => { Complete Your Payment

- Great news! Your membership application has been approved. Complete your payment to activate your membership and gain full access to all member benefits. + Great news! Your membership application has been validated. Complete your payment to activate your membership and gain full access to all member benefits.

- - + +

- Approval Queue + Validation Queue

- Review and approve pending membership applications. + Review and validate pending membership applications.

+ + {/* Users Needing Attention Widget */} + {usersNeedingAttention.length > 0 && ( +
+ +
+
+ +
+
+

+ Members Needing Personal Outreach +

+

+ These members have received multiple reminder emails. Consider calling them directly. +

+
+
+ +
+ {usersNeedingAttention.map(user => ( + +
+
+
+
+

+ {user.first_name} {user.last_name} +

+ + {user.totalReminders} reminder{user.totalReminders !== 1 ? 's' : ''} + +
+
+

Email: {user.email}

+

Phone: {user.phone || 'N/A'}

+

Status: {user.status.replace('_', ' ')}

+ {user.email_verification_reminders_sent > 0 && ( +

+ + {user.email_verification_reminders_sent} email verification reminder{user.email_verification_reminders_sent !== 1 ? 's' : ''} +

+ )} + {user.event_attendance_reminders_sent > 0 && ( +

+ + {user.event_attendance_reminders_sent} event reminder{user.event_attendance_reminders_sent !== 1 ? 's' : ''} +

+ )} + {user.payment_reminders_sent > 0 && ( +

+ + {user.payment_reminders_sent} payment reminder{user.payment_reminders_sent !== 1 ? 's' : ''} +

+ )} +
+
+ +
+
+ + ))} +
+ +
+

+ 💡 Tip for helping older members: Many of our members are older ladies who may struggle with email. + A friendly phone call can help them complete the registration process and feel more welcomed to the community. +

+
+
+
+ )} ); }; diff --git a/src/pages/admin/AdminEvents.js b/src/pages/admin/AdminEvents.js index 7eb418d..9c8b89c 100644 --- a/src/pages/admin/AdminEvents.js +++ b/src/pages/admin/AdminEvents.js @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { useAuth } from '../../context/AuthContext'; import api from '../../utils/api'; import { Card } from '../../components/ui/card'; import { Button } from '../../components/ui/button'; @@ -10,6 +11,7 @@ import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide import { AttendanceDialog } from '../../components/AttendanceDialog'; const AdminEvents = () => { + const { hasPermission } = useAuth(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); @@ -141,6 +143,7 @@ const AdminEvents = () => {

+ {(hasPermission('events.create') || hasPermission('events.edit')) && ( + )} {/* Events List */} diff --git a/src/pages/admin/AdminMembers.js b/src/pages/admin/AdminMembers.js index f1ad30d..b581e6b 100644 --- a/src/pages/admin/AdminMembers.js +++ b/src/pages/admin/AdminMembers.js @@ -1,18 +1,30 @@ import React, { useEffect, useState } from 'react'; import { useNavigate, useLocation, Link } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; import api from '../../utils/api'; import { Card } from '../../components/ui/card'; import { Button } from '../../components/ui/button'; import { Badge } from '../../components/ui/badge'; import { Input } from '../../components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../../components/ui/dropdown-menu'; import { toast } from 'sonner'; -import { Users, Search, User, CreditCard, Eye, CheckCircle } from 'lucide-react'; +import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown } from 'lucide-react'; import PaymentActivationDialog from '../../components/PaymentActivationDialog'; +import ConfirmationDialog from '../../components/ConfirmationDialog'; +import CreateMemberDialog from '../../components/CreateMemberDialog'; +import InviteStaffDialog from '../../components/InviteStaffDialog'; +import ImportMembersDialog from '../../components/ImportMembersDialog'; const AdminMembers = () => { const navigate = useNavigate(); const location = useLocation(); + const { hasPermission } = useAuth(); const [users, setUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]); const [loading, setLoading] = useState(true); @@ -20,6 +32,13 @@ const AdminMembers = () => { const [statusFilter, setStatusFilter] = useState('active'); const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); const [selectedUserForPayment, setSelectedUserForPayment] = useState(null); + const [statusChanging, setStatusChanging] = useState(null); + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [pendingStatusChange, setPendingStatusChange] = useState(null); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [exporting, setExporting] = useState(false); useEffect(() => { fetchMembers(); @@ -70,14 +89,127 @@ const AdminMembers = () => { fetchMembers(); // Refresh list }; + const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => { + // Skip confirmation if status didn't actually change + if (currentStatus === newStatus) return; + + setPendingStatusChange({ userId, newStatus, user }); + setConfirmDialogOpen(true); + }; + + const confirmStatusChange = async () => { + if (!pendingStatusChange) return; + + const { userId, newStatus } = pendingStatusChange; + setStatusChanging(userId); + setConfirmDialogOpen(false); + + try { + await api.put(`/admin/users/${userId}/status`, { status: newStatus }); + toast.success('Member status updated successfully'); + fetchMembers(); // Refresh list + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to update status'); + } finally { + setStatusChanging(null); + setPendingStatusChange(null); + } + }; + + const handleExport = async (filterType) => { + setExporting(true); + try { + let params = {}; + if (filterType === 'current') { + if (statusFilter && statusFilter !== 'all') { + params.status = statusFilter; + } + if (searchQuery) { + params.search = searchQuery; + } + } + // filterType === 'all' will export all members without filters + + const response = await api.get('/admin/users/export', { + params, + responseType: 'blob' + }); + + // Create download link + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `members_export_${new Date().toISOString().split('T')[0]}.csv`); + document.body.appendChild(link); + link.click(); + link.remove(); + + toast.success('Members exported successfully'); + } catch (error) { + toast.error('Failed to export members'); + } finally { + setExporting(false); + } + }; + + const getStatusChangeMessage = () => { + if (!pendingStatusChange) return {}; + + const { newStatus, user } = pendingStatusChange; + const userName = `${user.first_name} ${user.last_name}`; + + const messages = { + payment_pending: { + title: 'Revert to Payment Pending?', + description: `This will change ${userName}'s status back to Payment Pending. They will need to complete payment again to become active.`, + variant: 'warning', + confirmText: 'Yes, Revert Status', + }, + active: { + title: 'Activate Member?', + description: `This will activate ${userName}'s membership. They will gain full access to member features and resources.`, + variant: 'success', + confirmText: 'Yes, Activate', + }, + inactive: { + title: 'Deactivate Member?', + description: `This will deactivate ${userName}'s membership. They will lose access to member-only features but their data will be preserved.`, + variant: 'warning', + confirmText: 'Yes, Deactivate', + }, + canceled: { + title: 'Cancel Membership?', + description: `This will mark ${userName}'s membership as canceled. This indicates they voluntarily ended their membership. Their subscription will not auto-renew.`, + variant: 'danger', + confirmText: 'Yes, Cancel Membership', + }, + expired: { + title: 'Mark Membership as Expired?', + description: `This will mark ${userName}'s membership as expired. This indicates their subscription period has ended without renewal.`, + variant: 'warning', + confirmText: 'Yes, Mark as Expired', + }, + }; + + return messages[newStatus] || { + title: 'Confirm Status Change', + description: `Are you sure you want to change ${userName}'s status to ${newStatus}?`, + variant: 'warning', + confirmText: 'Confirm', + }; + }; + const getStatusBadge = (status) => { const config = { pending_email: { label: 'Pending Email', className: 'bg-orange-100 text-orange-700' }, - pending_approval: { label: 'Pending Approval', className: 'bg-gray-200 text-gray-700' }, - pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' }, + pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' }, + pre_validated: { label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' }, payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' }, active: { label: 'Active', className: 'bg-[#81B29A] text-white' }, - inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' } + inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' }, + canceled: { label: 'Canceled', className: 'bg-red-100 text-red-700' }, + expired: { label: 'Expired', className: 'bg-red-500 text-white' }, + abandoned: { label: 'Abandoned', className: 'bg-gray-300 text-gray-600' } }; const statusConfig = config[status] || config.inactive; @@ -88,15 +220,102 @@ const AdminMembers = () => { ); }; + const getReminderInfo = (user) => { + const emailReminders = user.email_verification_reminders_sent || 0; + const eventReminders = user.event_attendance_reminders_sent || 0; + const paymentReminders = user.payment_reminders_sent || 0; + const renewalReminders = user.renewal_reminders_sent || 0; + const totalReminders = emailReminders + eventReminders + paymentReminders + renewalReminders; + + return { + emailReminders, + eventReminders, + paymentReminders, + renewalReminders, + totalReminders, + lastReminderAt: user.last_email_verification_reminder_at || + user.last_event_attendance_reminder_at || + user.last_payment_reminder_at || + user.last_renewal_reminder_at + }; + }; + return ( <>
-

- Members Management -

-

- Manage paying members and their subscriptions. -

+
+
+

+ Members Management +

+

+ Manage paying members and their subscriptions. +

+
+
+ {hasPermission('users.export') && ( + + + + + + handleExport('all')} className="cursor-pointer"> + Export All Members + + handleExport('current')} className="cursor-pointer"> + Export Current View + + + + )} + + {hasPermission('users.import') && ( + + )} + + {hasPermission('users.invite') && ( + + )} + + {hasPermission('users.create') && ( + + )} +
+
{/* Stats */} @@ -148,9 +367,12 @@ const AdminMembers = () => { All Statuses Active Payment Pending - Pending Approval - Pre-Approved + Pending Validation + Pre-Validated Inactive + Canceled + Expired + Abandoned @@ -192,45 +414,112 @@ const AdminMembers = () => {

Referred by: {user.referred_by_member_name}

)} + + {/* Reminder Info */} + {(() => { + const reminderInfo = getReminderInfo(user); + if (reminderInfo.totalReminders > 0) { + return ( +
+
+ + + {reminderInfo.totalReminders} reminder{reminderInfo.totalReminders !== 1 ? 's' : ''} sent + {reminderInfo.totalReminders >= 3 && ( + + Needs attention + + )} + +
+
+ {reminderInfo.emailReminders > 0 && ( +

+ + {reminderInfo.emailReminders} email verification +

+ )} + {reminderInfo.eventReminders > 0 && ( +

+ + {reminderInfo.eventReminders} event attendance +

+ )} + {reminderInfo.paymentReminders > 0 && ( +

+ + {reminderInfo.paymentReminders} payment +

+ )} + {reminderInfo.renewalReminders > 0 && ( +

+ + {reminderInfo.renewalReminders} renewal +

+ )} +
+ {reminderInfo.lastReminderAt && ( +

+ Last reminder: {new Date(reminderInfo.lastReminderAt).toLocaleDateString()} at {new Date(reminderInfo.lastReminderAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

+ )} +
+ ); + } + return null; + })()} {/* Actions */} -
- - - +
+
+ + + - {/* Show Activate Payment button for payment_pending users */} - {user.status === 'payment_pending' && ( - - )} + {/* Show Activate Payment button for payment_pending users */} + {user.status === 'payment_pending' && ( + + )} +
- {/* Show Subscription button for active users */} - {user.status === 'active' && ( -
@@ -257,6 +546,34 @@ const AdminMembers = () => { user={selectedUserForPayment} onSuccess={handlePaymentSuccess} /> + + {/* Status Change Confirmation Dialog */} + + + {/* Create/Invite/Import Dialogs */} + + + + + ); }; diff --git a/src/pages/admin/AdminPermissions.js b/src/pages/admin/AdminPermissions.js new file mode 100644 index 0000000..3d048de --- /dev/null +++ b/src/pages/admin/AdminPermissions.js @@ -0,0 +1,415 @@ +import React, { useEffect, useState } from 'react'; +import { useAuth } from '../../context/AuthContext'; +import api from '../../utils/api'; +import { Card } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Checkbox } from '../../components/ui/checkbox'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '../../components/ui/alert-dialog'; +import { toast } from 'sonner'; +import { Shield, Save, Lock, ChevronDown, ChevronUp } from 'lucide-react'; + +const AdminPermissions = () => { + const { hasPermission } = useAuth(); + const [permissions, setPermissions] = useState([]); + const [rolePermissions, setRolePermissions] = useState({ + admin: [], + member: [], + guest: [] + }); + const [selectedPermissions, setSelectedPermissions] = useState({ + admin: [], + member: [], + guest: [] + }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [selectedRole, setSelectedRole] = useState('admin'); + const [expandedModules, setExpandedModules] = useState({}); + const [hasChanges, setHasChanges] = useState(false); + + useEffect(() => { + fetchPermissions(); + }, []); + + useEffect(() => { + // Check if there are unsaved changes + const changed = ['admin', 'member', 'guest'].some(role => { + const current = selectedPermissions[role].slice().sort(); + const original = rolePermissions[role].slice().sort(); + return JSON.stringify(current) !== JSON.stringify(original); + }); + setHasChanges(changed); + }, [selectedPermissions, rolePermissions]); + + const fetchPermissions = async () => { + try { + // Fetch all permissions + const permsResponse = await api.get('/admin/permissions'); + setPermissions(permsResponse.data); + + // Fetch permissions for each role + const roles = ['admin', 'member', 'guest']; + const rolePermsData = {}; + const selectedPermsData = {}; + + for (const role of roles) { + const response = await api.get(`/admin/permissions/roles/${role}`); + rolePermsData[role] = response.data.map(p => p.code); + selectedPermsData[role] = response.data.map(p => p.code); + } + + setRolePermissions(rolePermsData); + setSelectedPermissions(selectedPermsData); + + // Expand all modules by default + const modules = [...new Set(permsResponse.data.map(p => p.module))]; + const expanded = {}; + modules.forEach(module => { + expanded[module] = true; + }); + setExpandedModules(expanded); + } catch (error) { + toast.error('Failed to fetch permissions'); + } finally { + setLoading(false); + } + }; + + const togglePermission = (role, permissionCode) => { + setSelectedPermissions(prev => { + const current = prev[role] || []; + if (current.includes(permissionCode)) { + return { + ...prev, + [role]: current.filter(p => p !== permissionCode) + }; + } else { + return { + ...prev, + [role]: [...current, permissionCode] + }; + } + }); + }; + + const toggleModule = (role, module) => { + const modulePerms = permissions + .filter(p => p.module === module) + .map(p => p.code); + + const allSelected = modulePerms.every(code => + selectedPermissions[role].includes(code) + ); + + if (allSelected) { + // Deselect all module permissions + setSelectedPermissions(prev => ({ + ...prev, + [role]: prev[role].filter(p => !modulePerms.includes(p)) + })); + } else { + // Select all module permissions + setSelectedPermissions(prev => ({ + ...prev, + [role]: [...new Set([...prev[role], ...modulePerms])] + })); + } + }; + + const handleSave = () => { + setShowConfirmDialog(true); + }; + + const confirmSave = async () => { + setSaving(true); + setShowConfirmDialog(false); + + try { + // Save permissions for each role + await Promise.all([ + api.put(`/admin/permissions/roles/${selectedRole}`, { + permission_codes: selectedPermissions[selectedRole] + }) + ]); + + // Update original state + setRolePermissions(prev => ({ + ...prev, + [selectedRole]: [...selectedPermissions[selectedRole]] + })); + + toast.success(`Permissions updated for ${selectedRole}`); + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to update permissions'); + } finally { + setSaving(false); + } + }; + + const toggleModuleExpansion = (module) => { + setExpandedModules(prev => ({ + ...prev, + [module]: !prev[module] + })); + }; + + const groupedPermissions = permissions.reduce((acc, perm) => { + if (!acc[perm.module]) { + acc[perm.module] = []; + } + acc[perm.module].push(perm); + return acc; + }, {}); + + const getModuleProgress = (role, module) => { + const modulePerms = groupedPermissions[module] || []; + const selected = modulePerms.filter(p => + selectedPermissions[role].includes(p.code) + ).length; + return `${selected}/${modulePerms.length}`; + }; + + const isModuleFullySelected = (role, module) => { + const modulePerms = groupedPermissions[module] || []; + return modulePerms.every(p => selectedPermissions[role].includes(p.code)); + }; + + const getRoleBadge = (role) => { + const config = { + admin: { label: 'Admin', color: 'bg-[#81B29A]', icon: Shield }, + member: { label: 'Member', color: 'bg-[#664fa3]', icon: Shield }, + guest: { label: 'Guest', color: 'bg-gray-400', icon: Shield } + }; + + const roleConfig = config[role] || config.admin; + const Icon = roleConfig.icon; + + return ( +
+ + {roleConfig.label} +
+ ); + }; + + if (loading) { + return ( +
+

+ Loading permissions... +

+
+ ); + } + + if (!hasPermission('permissions.assign')) { + return ( +
+ +

+ Access Denied +

+

+ You don't have permission to manage role permissions. +

+

+ Only Superadmins can access this page. +

+
+ ); + } + + return ( + <> +
+

+ Permission Management +

+

+ Configure granular permissions for each role. Superadmin always has all permissions. +

+
+ + {/* Role Tabs */} + + + + {getRoleBadge('admin')} + + + {getRoleBadge('member')} + + + {getRoleBadge('guest')} + + + + {['admin', 'member', 'guest'].map(role => ( + + {/* Stats */} +
+ +

+ Total Permissions +

+

+ {permissions.length} +

+
+ +

+ Assigned +

+

+ {selectedPermissions[role].length} +

+
+ +

+ Modules +

+

+ {Object.keys(groupedPermissions).length} +

+
+
+ + {/* Permissions by Module */} +
+ {Object.entries(groupedPermissions).map(([module, perms]) => ( + + {/* Module Header */} +
toggleModuleExpansion(module)} + > +
+
+ toggleModule(role, module)} + onClick={(e) => e.stopPropagation()} + className="h-6 w-6 border-2 border-[#664fa3] data-[state=checked]:bg-[#664fa3]" + /> +
+

+ {module} +

+

+ {getModuleProgress(role, module)} permissions +

+
+
+ {expandedModules[module] ? ( + + ) : ( + + )} +
+
+ + {/* Module Permissions */} + {expandedModules[module] && ( +
+
+ {perms.map(perm => ( +
+ togglePermission(role, perm.code)} + className="mt-1 h-5 w-5 border-2 border-[#664fa3] data-[state=checked]:bg-[#664fa3]" + /> +
+

+ {perm.name} +

+

+ {perm.description} +

+

+ {perm.code} +

+
+
+ ))} +
+
+ )} +
+ ))} +
+
+ ))} +
+ + {/* Superadmin Note */} + +
+ +
+

+ Superadmin Permissions +

+

+ Superadmins automatically have all permissions and cannot be restricted. This ensures you can never lock yourself out of the system. +

+
+
+
+ + {/* Save Button */} + {hasChanges && ( +
+ +
+ )} + + {/* Confirmation Dialog */} + + + + + Confirm Permission Changes + + + Are you sure you want to update permissions for {selectedRole}? + This will immediately affect all users with this role. + + + + Cancel + + Confirm + + + + + + ); +}; + +export default AdminPermissions; diff --git a/src/pages/admin/AdminRoles.js b/src/pages/admin/AdminRoles.js new file mode 100644 index 0000000..51f2e9f --- /dev/null +++ b/src/pages/admin/AdminRoles.js @@ -0,0 +1,498 @@ +import React, { useEffect, useState } from 'react'; +import api from '../../utils/api'; +import { Card } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Input } from '../../components/ui/input'; +import { Label } from '../../components/ui/label'; +import { Textarea } from '../../components/ui/textarea'; +import { Checkbox } from '../../components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../../components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '../../components/ui/alert-dialog'; +import { toast } from 'sonner'; +import { Shield, Plus, Edit, Trash2, Lock, ChevronDown, ChevronUp } from 'lucide-react'; + +const AdminRoles = () => { + const [roles, setRoles] = useState([]); + const [permissions, setPermissions] = useState([]); + const [selectedRole, setSelectedRole] = useState(null); + const [rolePermissions, setRolePermissions] = useState([]); + const [selectedPermissions, setSelectedPermissions] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showPermissionsModal, setShowPermissionsModal] = useState(false); + const [expandedModules, setExpandedModules] = useState({}); + const [formData, setFormData] = useState({ + code: '', + name: '', + description: '', + permissions: [] + }); + + useEffect(() => { + fetchRoles(); + fetchPermissions(); + }, []); + + const fetchRoles = async () => { + try { + const response = await api.get('/admin/roles'); + setRoles(response.data); + setLoading(false); + } catch (error) { + toast.error('Failed to fetch roles'); + setLoading(false); + } + }; + + const fetchPermissions = async () => { + try { + const response = await api.get('/admin/permissions'); + setPermissions(response.data); + + // Expand all modules by default + const modules = [...new Set(response.data.map(p => p.module))]; + const expanded = {}; + modules.forEach(module => { + expanded[module] = true; + }); + setExpandedModules(expanded); + } catch (error) { + toast.error('Failed to fetch permissions'); + } + }; + + const fetchRolePermissions = async (roleId) => { + try { + const response = await api.get(`/admin/roles/${roleId}/permissions`); + setRolePermissions(response.data.permissions); + setSelectedPermissions(response.data.permissions.map(p => p.code)); + } catch (error) { + toast.error('Failed to fetch role permissions'); + } + }; + + const handleCreateRole = async () => { + try { + await api.post('/admin/roles', { + code: formData.code, + name: formData.name, + description: formData.description, + permission_codes: formData.permissions + }); + toast.success('Role created successfully'); + setShowCreateModal(false); + setFormData({ code: '', name: '', description: '', permissions: [] }); + fetchRoles(); + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to create role'); + } + }; + + const handleUpdateRole = async () => { + try { + await api.put(`/admin/roles/${selectedRole.id}`, { + name: formData.name, + description: formData.description + }); + toast.success('Role updated successfully'); + setShowEditModal(false); + fetchRoles(); + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to update role'); + } + }; + + const handleDeleteRole = async () => { + try { + await api.delete(`/admin/roles/${selectedRole.id}`); + toast.success('Role deleted successfully'); + setShowDeleteDialog(false); + setSelectedRole(null); + fetchRoles(); + } catch (error) { + toast.error(error.response?.data?.detail || 'Failed to delete role'); + } + }; + + const handleSavePermissions = async () => { + try { + await api.put(`/admin/roles/${selectedRole.id}/permissions`, { + permission_codes: selectedPermissions + }); + toast.success('Permissions updated successfully'); + setShowPermissionsModal(false); + fetchRoles(); + } catch (error) { + toast.error('Failed to update permissions'); + } + }; + + const togglePermission = (permissionCode) => { + setSelectedPermissions(prev => { + if (prev.includes(permissionCode)) { + return prev.filter(p => p !== permissionCode); + } else { + return [...prev, permissionCode]; + } + }); + }; + + const toggleModule = (module) => { + setExpandedModules(prev => ({ + ...prev, + [module]: !prev[module] + })); + }; + + const groupPermissionsByModule = () => { + const grouped = {}; + permissions.forEach(perm => { + if (!grouped[perm.module]) { + grouped[perm.module] = []; + } + grouped[perm.module].push(perm); + }); + return grouped; + }; + + if (loading) { + return ( +
+
Loading roles...
+
+ ); + } + + const groupedPermissions = groupPermissionsByModule(); + + return ( +
+ {/* Header */} +
+
+

Role Management

+

+ Create and manage custom roles with specific permissions +

+
+ +
+ + {/* Roles Grid */} +
+ {roles.map(role => ( + +
+
+ +
+

{role.name}

+

{role.code}

+
+
+ {role.is_system_role && ( + + )} +
+ +

+ {role.description || 'No description'} +

+ +
+ + {role.permission_count} permissions + +
+ +
+ + {!role.is_system_role && ( + <> + + + + )} +
+
+ ))} +
+ + {/* Create Role Modal */} + + + + Create New Role + + Create a custom role with specific permissions for your team members. + + + +
+
+ + setFormData({ ...formData, code: e.target.value })} + /> +

+ Lowercase, no spaces. Used internally to identify the role. +

+
+ +
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+ +
+ +