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.
+
+
+
+
+
+
+ );
+};
+
+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.
+
+
+
+
+
+
+ );
+};
+
+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
+
+
+
setFile(null)}
+ className="rounded-xl"
+ >
+ Remove File
+
+
+ ) : (
+
+
+
+
+ Drag and drop your CSV file here
+
+
or
+
+
+ Browse Files
+
+
+
+
+
+ )}
+
+
+ {/* Options */}
+
+
+
+ Update existing members (if email already exists)
+
+
+
+ ) : (
+ // 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 ? (
+ <>
+
+ Cancel
+
+
+ {loading ? (
+ <>
+
+ Importing...
+ >
+ ) : (
+ <>
+
+ Import Members
+ >
+ )}
+
+ >
+ ) : (
+
+ Done
+
+ )}
+
+
+
+ );
+};
+
+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
+
+
Invitation Link (expires in 7 days)
+
+
+
+ {copied ? (
+ <>
+
+ Copied
+ >
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+
+
+ ) : (
+ // Show invitation form
+
+ )}
+
+ {invitationUrl && (
+
+
+ Done
+
+
+ )}
+
+
+ );
+};
+
+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') && (
{
{/* Footer Actions */}
- {user?.role === 'admin' && (
+ {(user?.role === 'admin' || user?.role === 'superadmin') && (
setIsMobileMenuOpen(false)}
diff --git a/src/components/PendingInvitationsTable.js b/src/components/PendingInvitationsTable.js
new file mode 100644
index 0000000..aaece9a
--- /dev/null
+++ b/src/components/PendingInvitationsTable.js
@@ -0,0 +1,235 @@
+import React, { useEffect, useState } from 'react';
+import api from '../utils/api';
+import { Card } from './ui/card';
+import { Button } from './ui/button';
+import { Badge } from './ui/badge';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from './ui/table';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from './ui/alert-dialog';
+import { toast } from 'sonner';
+import { Mail, Trash2, MailCheck, Clock } from 'lucide-react';
+
+const PendingInvitationsTable = () => {
+ const [invitations, setInvitations] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [revokeDialog, setRevokeDialog] = useState({ open: false, invitation: null });
+ const [resending, setResending] = useState(null);
+
+ useEffect(() => {
+ fetchInvitations();
+ }, []);
+
+ const fetchInvitations = async () => {
+ try {
+ const response = await api.get('/admin/users/invitations?status=pending');
+ setInvitations(response.data);
+ } catch (error) {
+ toast.error('Failed to fetch invitations');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleResend = async (invitationId) => {
+ setResending(invitationId);
+ try {
+ await api.post(`/admin/users/invitations/${invitationId}/resend`);
+ toast.success('Invitation resent successfully');
+ fetchInvitations(); // Refresh list to show new expiry date
+ } catch (error) {
+ toast.error(error.response?.data?.detail || 'Failed to resend invitation');
+ } finally {
+ setResending(null);
+ }
+ };
+
+ const handleRevoke = async () => {
+ if (!revokeDialog.invitation) return;
+
+ try {
+ await api.delete(`/admin/users/invitations/${revokeDialog.invitation.id}`);
+ toast.success('Invitation revoked');
+ setRevokeDialog({ open: false, invitation: null });
+ fetchInvitations(); // Refresh list
+ } catch (error) {
+ toast.error(error.response?.data?.detail || 'Failed to revoke invitation');
+ }
+ };
+
+ const getRoleBadge = (role) => {
+ const config = {
+ superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
+ admin: { label: 'Admin', className: 'bg-[#81B29A] text-white' },
+ member: { label: 'Member', className: 'bg-[#DDD8EB] text-[#422268]' }
+ };
+
+ const roleConfig = config[role] || { label: role, className: 'bg-gray-500 text-white' };
+ return (
+
+ {roleConfig.label}
+
+ );
+ };
+
+ const isExpiringSoon = (expiresAt) => {
+ const expiry = new Date(expiresAt);
+ const now = new Date();
+ const hoursDiff = (expiry - now) / (1000 * 60 * 60);
+ return hoursDiff < 24 && hoursDiff > 0;
+ };
+
+ const formatDate = (dateString) => {
+ const date = new Date(dateString);
+ const now = new Date();
+ const hoursDiff = (date - now) / (1000 * 60 * 60);
+
+ if (hoursDiff < 0) {
+ return 'Expired';
+ } else if (hoursDiff < 24) {
+ return `Expires in ${Math.round(hoursDiff)} hours`;
+ } else {
+ const daysDiff = Math.round(hoursDiff / 24);
+ return `Expires in ${daysDiff} day${daysDiff > 1 ? 's' : ''}`;
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+ Loading invitations...
+
+
+ );
+ }
+
+ if (invitations.length === 0) {
+ return (
+
+
+
+ No Pending Invitations
+
+
+ All invitations have been accepted or expired
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ Email
+ Name
+ Role
+ Invited
+ Expires
+ Actions
+
+
+
+ {invitations.map((invitation) => (
+
+
+ {invitation.email}
+
+
+ {invitation.first_name && invitation.last_name
+ ? `${invitation.first_name} ${invitation.last_name}`
+ : '-'}
+
+ {getRoleBadge(invitation.role)}
+
+ {new Date(invitation.invited_at).toLocaleDateString()}
+
+
+
+
+
+ {formatDate(invitation.expires_at)}
+
+
+
+
+
+ handleResend(invitation.id)}
+ disabled={resending === invitation.id}
+ className="rounded-xl border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
+ >
+ {resending === invitation.id ? (
+ 'Resending...'
+ ) : (
+ <>
+
+ Resend
+ >
+ )}
+
+ setRevokeDialog({ open: true, invitation })}
+ className="rounded-xl border-red-500 text-red-500 hover:bg-red-500 hover:text-white"
+ >
+
+ Revoke
+
+
+
+
+ ))}
+
+
+
+
+ {/* Revoke Confirmation Dialog */}
+
setRevokeDialog({ open, invitation: null })}>
+
+
+
+ Revoke Invitation
+
+
+ Are you sure you want to revoke the invitation for{' '}
+ {revokeDialog.invitation?.email} ?
+ This action cannot be undone.
+
+
+
+ Cancel
+
+ Revoke
+
+
+
+
+ >
+ );
+};
+
+export default PendingInvitationsTable;
diff --git a/src/components/PublicFooter.js b/src/components/PublicFooter.js
index 60e9c15..7f2f9c5 100644
--- a/src/components/PublicFooter.js
+++ b/src/components/PublicFooter.js
@@ -8,63 +8,61 @@ const PublicFooter = () => {
return (
<>
{/* Main Footer */}
-
-
-
+
+
+
-
-
-
-
About
+
+
+
-
History
-
Mission and Values
-
Board of Directors
+
History
+
Mission and Values
+
Board of Directors
-
-
-
Connect
+
+
-
Become a Member
-
Contact Us
-
Resources
+
Become a Member
+
Contact Us
+
Resources
-
-
+
+
-
+
Donate
-
-
- LOAF is supported by the Hollyfield Foundation
-
-
+
+ LOAF is supported by the Hollyfield Foundation
+
{/* Bottom Footer */}
-
+
-
+
Terms of Service
-
-
+
+
Privacy Policy
-
+
-
+
© 2025 LOAF. All Rights Reserved.
-
+
Designed and Managed by{' '}
-
+
Koncept Kit
diff --git a/src/components/registration/RegistrationStep4.js b/src/components/registration/RegistrationStep4.js
index 635ca9f..4e2b408 100644
--- a/src/components/registration/RegistrationStep4.js
+++ b/src/components/registration/RegistrationStep4.js
@@ -13,7 +13,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
Your email is also your username that you can use to login.
- Please note you can only login after your application is approved.
+ Please note you can only login after your application is validated.
@@ -69,6 +69,43 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
)}
+
+ {/* Terms of Service Acceptance */}
+
);
diff --git a/src/context/AuthContext.js b/src/context/AuthContext.js
index 2a1da0c..792f25f 100644
--- a/src/context/AuthContext.js
+++ b/src/context/AuthContext.js
@@ -9,6 +9,7 @@ export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [token, setToken] = useState(localStorage.getItem('token'));
+ const [permissions, setPermissions] = useState([]);
useEffect(() => {
const initAuth = async () => {
@@ -20,9 +21,13 @@ export const AuthProvider = ({ children }) => {
});
setUser(response.data);
setToken(storedToken);
+
+ // Fetch user permissions
+ await fetchPermissions(storedToken);
} catch (error) {
localStorage.removeItem('token');
setToken(null);
+ setPermissions([]);
}
}
setLoading(false);
@@ -30,12 +35,34 @@ export const AuthProvider = ({ children }) => {
initAuth();
}, []);
+ const fetchPermissions = async (authToken) => {
+ try {
+ const tokenToUse = authToken || token || localStorage.getItem('token');
+ if (!tokenToUse) {
+ setPermissions([]);
+ return;
+ }
+
+ const response = await axios.get(`${API_URL}/api/auth/permissions`, {
+ headers: { Authorization: `Bearer ${tokenToUse}` }
+ });
+ setPermissions(response.data.permissions || []);
+ } catch (error) {
+ console.error('Failed to fetch permissions:', error);
+ setPermissions([]);
+ }
+ };
+
const login = async (email, password) => {
const response = await axios.post(`${API_URL}/api/auth/login`, { email, password });
const { access_token, user: userData } = response.data;
localStorage.setItem('token', access_token);
setToken(access_token);
setUser(userData);
+
+ // Fetch user permissions
+ await fetchPermissions(access_token);
+
return userData;
};
@@ -43,6 +70,7 @@ export const AuthProvider = ({ children }) => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
+ setPermissions([]);
};
const register = async (userData) => {
@@ -124,10 +152,18 @@ export const AuthProvider = ({ children }) => {
return response.data;
};
+ const hasPermission = (permissionCode) => {
+ if (!user) return false;
+ // Superadmin always has all permissions
+ if (user.role === 'superadmin') return true;
+ return permissions.includes(permissionCode);
+ };
+
return (
{
resetPassword,
changePassword,
resendVerificationEmail,
+ hasPermission,
loading
}}>
{children}
diff --git a/src/pages/AcceptInvitation.js b/src/pages/AcceptInvitation.js
new file mode 100644
index 0000000..2be4be0
--- /dev/null
+++ b/src/pages/AcceptInvitation.js
@@ -0,0 +1,461 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate, useSearchParams } 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 { Input } from '../components/ui/input';
+import { Label } from '../components/ui/label';
+import { Alert, AlertDescription } from '../components/ui/alert';
+import { Badge } from '../components/ui/badge';
+import { toast } from 'sonner';
+import { Loader2, Mail, Shield, CheckCircle, XCircle, Calendar } from 'lucide-react';
+
+const AcceptInvitation = () => {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const { login } = useAuth();
+ const [token, setToken] = useState(null);
+ const [invitation, setInvitation] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [formData, setFormData] = useState({
+ password: '',
+ confirmPassword: '',
+ first_name: '',
+ last_name: '',
+ phone: '',
+ address: '',
+ city: '',
+ state: '',
+ zipcode: '',
+ date_of_birth: ''
+ });
+ const [formErrors, setFormErrors] = useState({});
+
+ useEffect(() => {
+ const invitationToken = searchParams.get('token');
+ if (!invitationToken) {
+ setError('Invalid invitation link. No token provided.');
+ setLoading(false);
+ return;
+ }
+
+ setToken(invitationToken);
+ verifyInvitation(invitationToken);
+ }, [searchParams]);
+
+ const verifyInvitation = async (invitationToken) => {
+ try {
+ const response = await api.get(`/invitations/verify/${invitationToken}`);
+ setInvitation(response.data);
+
+ // Pre-fill form with invitation data
+ setFormData(prev => ({
+ ...prev,
+ first_name: response.data.first_name || '',
+ last_name: response.data.last_name || '',
+ phone: response.data.phone || ''
+ }));
+
+ setLoading(false);
+ } catch (error) {
+ setError(error.response?.data?.detail || 'Invalid or expired invitation token');
+ setLoading(false);
+ }
+ };
+
+ const handleChange = (field, value) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ // Clear error when user starts typing
+ if (formErrors[field]) {
+ setFormErrors(prev => ({ ...prev, [field]: null }));
+ }
+ };
+
+ const validate = () => {
+ const newErrors = {};
+
+ if (!formData.password || formData.password.length < 8) {
+ newErrors.password = 'Password must be at least 8 characters';
+ }
+
+ if (formData.password !== formData.confirmPassword) {
+ newErrors.confirmPassword = 'Passwords do not match';
+ }
+
+ 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';
+ }
+
+ setFormErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (!validate()) {
+ return;
+ }
+
+ setSubmitting(true);
+
+ try {
+ // Prepare payload
+ const payload = {
+ token,
+ password: formData.password,
+ first_name: formData.first_name,
+ last_name: formData.last_name,
+ phone: formData.phone
+ };
+
+ // Add optional fields if provided
+ if (formData.address) payload.address = formData.address;
+ if (formData.city) payload.city = formData.city;
+ if (formData.state) payload.state = formData.state;
+ if (formData.zipcode) payload.zipcode = formData.zipcode;
+ if (formData.date_of_birth) payload.date_of_birth = formData.date_of_birth;
+
+ // Accept invitation
+ const response = await api.post('/invitations/accept', payload);
+
+ // Auto-login with returned token
+ const { access_token, user } = response.data;
+ localStorage.setItem('token', access_token);
+
+ toast.success('Welcome to LOAF! Your account has been created successfully.');
+
+ // Call login to update auth context
+ if (login) {
+ await login(invitation.email, formData.password);
+ }
+
+ // Redirect based on role
+ if (user.role === 'admin' || user.role === 'superadmin') {
+ navigate('/admin/dashboard');
+ } else {
+ navigate('/dashboard');
+ }
+ } catch (error) {
+ const errorMessage = error.response?.data?.detail || 'Failed to accept invitation';
+ toast.error(errorMessage);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const getRoleBadge = (role) => {
+ const config = {
+ superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
+ admin: { label: 'Admin', className: 'bg-[#81B29A] text-white' },
+ member: { label: 'Member', className: 'bg-[#DDD8EB] text-[#422268]' }
+ };
+
+ const roleConfig = config[role] || { label: role, className: 'bg-gray-500 text-white' };
+ return (
+
+
+ {roleConfig.label}
+
+ );
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+ Verifying your invitation...
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Invalid Invitation
+
+
+ {error}
+
+ navigate('/login')}
+ className="rounded-xl bg-[#664fa3] hover:bg-[#422268] text-white"
+ >
+ Go to Login
+
+
+
+ );
+ }
+
+ 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 */}
+
+
+ {/* Footer Note */}
+
+
+ Already have an account?{' '}
+ navigate('/login')}
+ className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
+ >
+ Sign in instead
+
+
+
+
+
+ );
+};
+
+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.
{
return;
}
- if (user.role === 'admin') {
+ if (user.role === 'admin' || user.role === 'superadmin') {
navigate('/admin');
} else {
navigate('/dashboard');
diff --git a/src/pages/PaymentCancel.js b/src/pages/PaymentCancel.js
index aac99fa..a1ef773 100644
--- a/src/pages/PaymentCancel.js
+++ b/src/pages/PaymentCancel.js
@@ -64,7 +64,7 @@ const PaymentCancel = () => {
Note: {' '}
- Your membership application is still approved. You can complete payment whenever you're ready.
+ Your membership application is still validated. You can complete payment whenever you're ready.
diff --git a/src/pages/Plans.js b/src/pages/Plans.js
index 8459c55..a6fc137 100644
--- a/src/pages/Plans.js
+++ b/src/pages/Plans.js
@@ -47,16 +47,16 @@ const Plans = () => {
canView: true,
canSubscribe: false
},
- pending_approval: {
+ pending_validation: {
title: "Application Under Review",
- message: "Your application is being reviewed by our admin team. You'll receive an email once approved to proceed with payment.",
+ message: "Your application is being reviewed by our admin team. You'll receive an email once validated to proceed with payment.",
action: null,
canView: true,
canSubscribe: false
},
- pre_approved: {
+ pre_validated: {
title: "Application Under Review",
- message: "Your application is being reviewed by our admin team. You'll receive an email once approved to proceed with payment.",
+ message: "Your application is being reviewed by our admin team. You'll receive an email once validated to proceed with payment.",
action: null,
canView: true,
canSubscribe: false
@@ -77,10 +77,31 @@ const Plans = () => {
},
inactive: {
title: "Membership Inactive",
- message: "Your membership has expired. Please select a plan below to renew your membership.",
+ message: "Your membership is currently inactive. Please contact support for assistance.",
+ action: null,
+ canView: true,
+ canSubscribe: false
+ },
+ canceled: {
+ title: "Membership Canceled",
+ message: "Your membership was canceled. You can rejoin by selecting a plan below.",
action: null,
canView: true,
canSubscribe: true
+ },
+ expired: {
+ title: "Membership Expired",
+ message: "Your membership has expired. Please renew by selecting a plan below.",
+ action: null,
+ canView: true,
+ canSubscribe: true
+ },
+ abandoned: {
+ title: "Application Incomplete",
+ message: "Your application was not completed. Please contact support to restart the registration process.",
+ action: null,
+ canView: true,
+ canSubscribe: false
}
};
@@ -315,7 +336,7 @@ const Plans = () => {
Processing...
>
) : statusInfo && !statusInfo.canSubscribe ? (
- 'Approval Required'
+ 'Validation Required'
) : (
'Choose Amount & Subscribe'
)}
diff --git a/src/pages/PrivacyPolicy.js b/src/pages/PrivacyPolicy.js
new file mode 100644
index 0000000..f800eaa
--- /dev/null
+++ b/src/pages/PrivacyPolicy.js
@@ -0,0 +1,249 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import PublicNavbar from '../components/PublicNavbar';
+import PublicFooter from '../components/PublicFooter';
+
+export default function PrivacyPolicy() {
+ return (
+ <>
+
+
+
+ {/* Header */}
+
+
+ Privacy Policy
+
+
+ LOAFers, Inc. Website Privacy Policy
+
+
+
+ {/* Content */}
+
+
+ {/* Introduction */}
+
+
+
+ This Privacy Policy ("Policy") applies to Membership Applications, and LOAFers, Inc. ("Company") and governs data collection and usage. The Company's application is a Membership request, Membership online profile, and Consent to receive eNewsletters. By using the Company application, you consent to the data practices described in the statement.
+
+
+ We reserve the right to change this policy at any given time, of which you will be promptly updated. If you want to make sure that you are up to date with the latest changes, we advise you to frequently visit this page.
+
+
+
+
+ {/* Section 1: What User Data We Collect */}
+
+
+ 💻 What User Data We Collect
+
+
+
+ When you visit the Site, we may collect the following data:
+
+
+ Your IP address
+ Your contact information and email address
+
+
+
+ When you apply for membership, we collect the following data:
+
+
+ First and last name
+ Mailing address
+ Email
+ Phone number
+ Birthday
+
+
+
+ If you choose to pay your membership administrative fee online, we have access to:
+
+
+ Partial credit card information
+
+
+
+ You may also choose to provide the following:
+
+
+ Partner's name
+ Photo
+ Self-bio
+ Consent to receive our eNewsletter
+ Consent to display an online profile visible only to membership
+
+
+
+
+ {/* Section 2: Why We Collect Your Data */}
+
+
+ 🎯 Why We Collect Your Data
+
+
+
+ To send you announcement emails containing the information about our events and information we think you will find interesting.
+ To contact you to fill out surveys about our membership.
+ To customize our blog according to your online behavior and personal preferences.
+
+
+
+
+ {/* Section 3: Sharing Information with Third Parties */}
+
+
+ 🤝 Sharing Information with Third Parties
+
+
+
+ The Company does not sell, rent, or lease personal data to third parties.
+
+
+ The Company may share data with trusted partners to help perform statistical analysis, provide customer support.
+
+
+ The Company uses Stripe to process online payments at which time users would no longer be governed by the Company's Privacy Policy.
+
+
+ The Company may disclose your personal information, without notice, if required to do so by law.
+
+
+
+
+ {/* Section 4: Safeguarding and Securing the Data */}
+
+
+ 🔒 Safeguarding and Securing the Data
+
+
+
+ LOAFers, Inc. is committed to securing your data and keeping it confidential. LOAFers, Inc. has done all in its power to prevent data theft, unauthorized access, and disclosure by implementing the latest technologies and software, which help us safeguard all the information we collect online.
+
+
+
+
+ {/* Section 5: Our Cookie Policy */}
+
+
+ 🍪 Our Cookie Policy
+
+
+
+ Once you agree to allow our blog to use cookies, you also agree to use the data it collects regarding your online behavior (analyze web traffic, web pages you visit and spend the most time on).
+
+
+ The data we collect by using cookies is used to customize our blog to your needs. After we use the data for statistical analysis, the data is completely removed from our systems.
+
+
+ Please note that cookies don't allow us to gain control of your computer in any way. They are strictly used to monitor which pages you find useful and which you do not so that we can provide a better experience for you.
+
+
+ If you want to disable cookies, you can do it by accessing the settings of your internet browser. You can visit{' '}
+
+ https://www.internetcookies.com
+ , which contains comprehensive information on how to do this on a wide variety of browsers and devices.
+
+
+
+
+ {/* Section 6: Links to Other Websites */}
+
+
+ 🔗 Links to Other Websites
+
+
+
+ Our blog contains links that lead to other websites. If you click on these links LOAFers, Inc. is not held responsible for your data and privacy protection. Visiting those websites is not governed by this privacy policy agreement. Make sure to read the privacy policy documentation of the website you go to from our website.
+
+
+
+
+ {/* Section 7: Restricting the Collection of your Personal Data */}
+
+
+ 🚫 Restricting the Collection of your Personal Data
+
+
+
+ At some point, you might wish to restrict the use and collection of your personal data. You can achieve this by doing the following:
+
+
+ Log in to your online profile and make any changes you wish to your profile information.
+ If you have already agreed to share your information with us, feel free to contact us via email and we will be more than happy to change this for you.
+
+
+
+
+ {/* Section 8: Children Under Thirteen */}
+
+
+ 👶 Children Under Thirteen
+
+
+
+ The Company does not knowingly collect information from children under the age of 13.
+
+
+
+
+ {/* Section 9: Changes to this Statement */}
+
+
+ 🗓️ Changes to this Statement
+
+
+
+ The Company may make changes to this Policy. When this occurs the effective date of this policy will be updated.
+
+
+
+
+ {/* Section 10: Contact Information */}
+
+
+ 📧 Contact Information
+
+
+
+ If you have any questions, please contact LOAFers, Inc. at:
+
+
+
LOAFers, Inc.
+
PO Box 7207
+
Houston, TX 77248-7207
+
+ Email: info@loaftx.org
+
+
+
+
+
+
+ {/* Back to Home Link */}
+
+
+ ← Back to Home
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/pages/TermsOfService.js b/src/pages/TermsOfService.js
new file mode 100644
index 0000000..a76430d
--- /dev/null
+++ b/src/pages/TermsOfService.js
@@ -0,0 +1,317 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import PublicNavbar from '../components/PublicNavbar';
+import PublicFooter from '../components/PublicFooter';
+
+export default function TermsOfService() {
+ return (
+ <>
+
+
+
+ {/* Header */}
+
+
+ Terms of Service
+
+
+ Last Updated: January 2025
+
+
+
+ {/* Content */}
+
+
+ {/* Section 1: Agreement to Terms */}
+
+
+ 1. Agreement to Terms
+
+
+
+ These Terms of Service constitute a legally binding agreement made between you, whether personally or on behalf of an entity ("you") and LOAFers, Inc. ("Company", "we", "us", or "our"), concerning your access to and use of the https://loaftx.org website as well as any other media form, media channel, mobile website or mobile application related, linked, or otherwise connected thereto (collectively, the "Site").
+
+
+ You agree that by accessing the Site, you have read, understood, and agree to be bound by all of these Terms of Service. If you do not agree with all of these Terms of Service, then you are expressly prohibited from using the Site and you must discontinue use immediately.
+
+
+
+
+ {/* Section 2: Intellectual Property Rights */}
+
+
+ 2. Intellectual Property Rights
+
+
+
+ Unless otherwise indicated, the Site is our proprietary property and all source code, databases, functionality, software, website designs, audio, video, text, photographs, and graphics on the Site (collectively, the "Content") and the trademarks, service marks, and logos contained therein (the "Marks") are owned or controlled by us or licensed to us, and are protected by copyright and trademark laws and various other intellectual property rights and unfair competition laws of the United States, foreign jurisdictions, and international conventions.
+
+
+
+
+ {/* Section 3: User Representations */}
+
+
+ 3. User Representations
+
+
+
+ By using the Site, you represent and warrant that:
+
+
+ All registration information you submit will be true, accurate, current, and complete
+ You will maintain the accuracy of such information and promptly update such registration information as necessary
+ You have the legal capacity and you agree to comply with these Terms of Service
+ You are not under the age of 13
+ Not a minor in the jurisdiction in which you reside, or if a minor, you have received parental permission to use the Site
+ You will not access the Site through automated or non-human means
+ You will not use the Site for any illegal or unauthorized purpose
+ Your use of the Site will not violate any applicable law or regulation
+
+
+
+
+ {/* Section 4: Prohibited Activities */}
+
+
+ 4. Prohibited Activities
+
+
+
+ You may not access or use the Site for any purpose other than that for which we make the Site available. The Site may not be used in connection with any commercial endeavors except those that are specifically endorsed or approved by us. As a user of the Site, you agree not to:
+
+
+ Systematically retrieve data or other content from the Site to create or compile, directly or indirectly, a collection, compilation, database, or directory without written permission from us
+ Make any unauthorized use of the Site, including collecting usernames and/or email addresses of users by electronic or other means for the purpose of sending unsolicited email, or creating user accounts by automated means or under false pretenses
+ Circumvent, disable, or otherwise interfere with security-related features of the Site
+ Engage in unauthorized framing of or linking to the Site
+ Trick, defraud, or mislead us and other users, especially in any attempt to learn sensitive account information such as user passwords
+ Make improper use of our support services or submit false reports of abuse or misconduct
+ Engage in any automated use of the system, such as using scripts to send comments or messages
+ Interfere with, disrupt, or create an undue burden on the Site or the networks or services connected to the Site
+
+
+
+
+ {/* Section 5: User Generated Contributions */}
+
+
+ 5. User Generated Contributions
+
+
+
+ The Site may invite you to chat, contribute to, or participate in blogs, message boards, online forums, and other functionality, and may provide you with the opportunity to create, submit, post, display, transmit, perform, publish, distribute, or broadcast content and materials to us or on the Site.
+
+
+
+
+ {/* Section 6: Contribution License */}
+
+
+ 6. Contribution License
+
+
+
+ By posting your Contributions to any part of the Site, you automatically grant, and you represent and warrant that you have the right to grant, to us an unrestricted, unlimited, irrevocable, perpetual, non-exclusive, transferable, royalty-free, fully-paid, worldwide right, and license to host, use, copy, reproduce, disclose, sell, resell, publish, broadcast, retitle, archive, store, cache, publicly perform, publicly display, reformat, translate, transmit, excerpt (in whole or in part), and distribute such Contributions.
+
+
+
+
+ {/* Section 7: Submissions */}
+
+
+ 7. Submissions
+
+
+
+ You acknowledge and agree that any questions, comments, suggestions, ideas, feedback, or other information regarding the Site ("Submissions") provided by you to us are non-confidential and shall become our sole property.
+
+
+
+
+ {/* Section 8: Site Management */}
+
+
+ 8. Site Management
+
+
+
+ We reserve the right, but not the obligation, to: (1) monitor the Site for violations of these Terms of Service; (2) take appropriate legal action against anyone who, in our sole discretion, violates the law or these Terms of Service; (3) refuse, restrict access to, limit the availability of, or disable (to the extent technologically feasible) any of your Contributions; (4) remove from the Site or otherwise disable all files and content that are excessive in size or are in any way burdensome to our systems.
+
+
+
+
+ {/* Section 9: Term and Termination */}
+
+
+ 9. Term and Termination
+
+
+
+ These Terms of Service shall remain in full force and effect while you use the Site. Without limiting any other provision of these Terms of Service, we reserve the right to, in our sole discretion and without notice or liability, deny access to and use of the Site to any person for any reason or for no reason.
+
+
+
+
+ {/* Section 10: Modifications and Interruptions */}
+
+
+ 10. Modifications and Interruptions
+
+
+
+ We reserve the right to change, modify, or remove the contents of the Site at any time or for any reason at our sole discretion without notice. We also reserve the right to modify or discontinue all or part of the Site without notice at any time.
+
+
+
+
+ {/* Section 11: Governing Law */}
+
+
+ 11. Governing Law
+
+
+
+ These Terms of Service and your use of the Site are governed by and construed in accordance with the laws of the State of Texas applicable to agreements made and to be entirely performed within the State of Texas, without regard to its conflict of law principles.
+
+
+
+
+ {/* Section 12: Dispute Resolution */}
+
+
+ 12. Dispute Resolution
+
+
+
+ Any legal action of whatever nature brought by either you or us shall be commenced or prosecuted in the state and federal courts located in Harris County, Texas, and the parties hereby consent to, and waive all defenses of lack of personal jurisdiction and forum non conveniens with respect to venue and jurisdiction in such state and federal courts.
+
+
+
+
+ {/* Section 13: Corrections */}
+
+
+ 13. Corrections
+
+
+
+ There may be information on the Site that contains typographical errors, inaccuracies, or omissions that may relate to the Site, including descriptions, pricing, availability, and various other information. We reserve the right to correct any errors, inaccuracies, or omissions and to change or update the information on the Site at any time, without prior notice.
+
+
+
+
+ {/* Section 14: Disclaimer */}
+
+
+ 14. Disclaimer
+
+
+
+ The Site is provided on an as-is and as-available basis. You agree that your use of the Site and our services will be at your sole risk. To the fullest extent permitted by law, we disclaim all warranties, express or implied, in connection with the Site and your use thereof.
+
+
+
+
+ {/* Section 15: Limitations of Liability */}
+
+
+ 15. Limitations of Liability
+
+
+
+ In no event will we or our directors, employees, or agents be liable to you or any third party for any direct, indirect, consequential, exemplary, incidental, special, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising from your use of the Site.
+
+
+
+
+ {/* Section 16: Indemnification */}
+
+
+ 16. Indemnification
+
+
+
+ You agree to defend, indemnify, and hold us harmless, including our subsidiaries, affiliates, and all of our respective officers, agents, partners, and employees, from and against any loss, damage, liability, claim, or demand, including reasonable attorneys' fees and expenses, made by any third party due to or arising out of your use of the Site or breach of these Terms of Service.
+
+
+
+
+ {/* Section 17: User Data */}
+
+
+ 17. User Data
+
+
+
+ We will maintain certain data that you transmit to the Site for the purpose of managing the Site, as well as data relating to your use of the Site. Although we perform regular routine backups of data, you are solely responsible for all data that you transmit or that relates to any activity you have undertaken using the Site.
+
+
+
+
+ {/* Section 18: Electronic Communications */}
+
+
+ 18. Electronic Communications, Transactions, and Signatures
+
+
+
+ Visiting the Site, sending us emails, and completing online forms constitute electronic communications. You consent to receive electronic communications, and you agree that all agreements, notices, disclosures, and other communications we provide to you electronically, via email and on the Site, satisfy any legal requirement that such communication be in writing.
+
+
+
+
+ {/* Section 19: Contact Us */}
+
+
+ 19. Contact Us
+
+
+
+ In order to resolve a complaint regarding the Site or to receive further information regarding use of the Site, please contact us at:
+
+
+
LOAFers, Inc.
+
PO Box 7207
+
Houston, TX 77249
+
+ Email: info@loaftx.org
+
+
+
+
+
+
+ {/* Back to Home Link */}
+
+
+ ← Back to Home
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/pages/admin/AdminDashboard.js b/src/pages/admin/AdminDashboard.js
index fe0c85b..c965695 100644
--- a/src/pages/admin/AdminDashboard.js
+++ b/src/pages/admin/AdminDashboard.js
@@ -4,14 +4,15 @@ import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
-import { Users, Calendar, Clock, CheckCircle } from 'lucide-react';
+import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle } from 'lucide-react';
const AdminDashboard = () => {
const [stats, setStats] = useState({
totalMembers: 0,
- pendingApprovals: 0,
+ pendingValidations: 0,
activeMembers: 0
});
+ const [usersNeedingAttention, setUsersNeedingAttention] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -25,11 +26,27 @@ const AdminDashboard = () => {
setStats({
totalMembers: users.filter(u => u.role === 'member').length,
- pendingApprovals: users.filter(u =>
- ['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(u.status)
+ pendingValidations: users.filter(u =>
+ ['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
).length,
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length
});
+
+ // Find users who have received 3+ reminders (may need personal outreach)
+ const needingAttention = users.filter(u => {
+ const emailReminders = u.email_verification_reminders_sent || 0;
+ const eventReminders = u.event_attendance_reminders_sent || 0;
+ const paymentReminders = u.payment_reminders_sent || 0;
+ const totalReminders = emailReminders + eventReminders + paymentReminders;
+ return totalReminders >= 3;
+ }).map(u => ({
+ ...u,
+ totalReminders: (u.email_verification_reminders_sent || 0) +
+ (u.event_attendance_reminders_sent || 0) +
+ (u.payment_reminders_sent || 0)
+ })).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5
+
+ setUsersNeedingAttention(needingAttention);
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
@@ -62,16 +79,16 @@ const AdminDashboard = () => {
Total Members
-
+
- {loading ? '-' : stats.pendingApprovals}
+ {loading ? '-' : stats.pendingValidations}
- Pending Approvals
+ Pending Validations
@@ -89,42 +106,123 @@ const AdminDashboard = () => {
{/* Quick Actions */}
-
+
- Manage Users
+ Manage Members
- View and manage all registered users and their membership status.
+ View and manage paying members and their subscription status.
- Go to Users
+ Go to Members
-
-
+
+
- Approval Queue
+ Validation Queue
- Review and approve pending membership applications.
+ Review and validate pending membership applications.
- View Approvals
+ View Validations
+
+ {/* 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' : ''}
+
+ )}
+
+
+
{
+ e.preventDefault();
+ window.location.href = `tel:${user.phone}`;
+ }}
+ >
+ Call Member
+
+
+
+
+ ))}
+
+
+
+
+ 💡 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') && (
+
+
+
+ {exporting ? (
+ <>
+
+ Exporting...
+ >
+ ) : (
+ <>
+
+ Export
+
+ >
+ )}
+
+
+
+ handleExport('all')} className="cursor-pointer">
+ Export All Members
+
+ handleExport('current')} className="cursor-pointer">
+ Export Current View
+
+
+
+ )}
+
+ {hasPermission('users.import') && (
+ setImportDialogOpen(true)}
+ className="bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-xl h-12 px-6"
+ >
+
+ Import
+
+ )}
+
+ {hasPermission('users.invite') && (
+ setInviteDialogOpen(true)}
+ className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
+ >
+
+ Invite Member
+
+ )}
+
+ {hasPermission('users.create') && (
+ setCreateDialogOpen(true)}
+ className="bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-xl h-12 px-6"
+ >
+
+ Create Member
+
+ )}
+
+
{/* 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 */}
-
-
-
-
- View Profile
-
-
+
+
+
+
+
+ View Profile
+
+
- {/* Show Activate Payment button for payment_pending users */}
- {user.status === 'payment_pending' && (
- handleActivatePayment(user)}
- size="sm"
- className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
- >
-
- Activate Payment
-
- )}
+ {/* Show Activate Payment button for payment_pending users */}
+ {user.status === 'payment_pending' && (
+ handleActivatePayment(user)}
+ size="sm"
+ className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
+ >
+
+ Activate Payment
+
+ )}
+
- {/* Show Subscription button for active users */}
- {user.status === 'active' && (
-
+
+ Change Status:
+
+ handleStatusChangeRequest(user.id, user.status, newStatus, user)}
+ disabled={statusChanging === user.id}
>
-
- Subscription
-
- )}
+
+
+
+
+ Payment Pending
+ Active
+ Inactive
+ Canceled
+ Expired
+
+
+
@@ -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 && (
+
+
+
+ {saving ? 'Saving...' : 'Save Changes'}
+
+
+ )}
+
+ {/* 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 (
+
+ );
+ }
+
+ const groupedPermissions = groupPermissionsByModule();
+
+ return (
+
+ {/* Header */}
+
+
+
Role Management
+
+ Create and manage custom roles with specific permissions
+
+
+
setShowCreateModal(true)}>
+
+ Create Role
+
+
+
+ {/* Roles Grid */}
+
+ {roles.map(role => (
+
+
+
+
+
+
{role.name}
+
{role.code}
+
+
+ {role.is_system_role && (
+
+ )}
+
+
+
+ {role.description || 'No description'}
+
+
+
+
+ {role.permission_count} permissions
+
+
+
+
+ {
+ setSelectedRole(role);
+ fetchRolePermissions(role.id);
+ setShowPermissionsModal(true);
+ }}
+ >
+ Manage Permissions
+
+ {!role.is_system_role && (
+ <>
+ {
+ setSelectedRole(role);
+ setFormData({
+ name: role.name,
+ description: role.description || ''
+ });
+ setShowEditModal(true);
+ }}
+ >
+
+
+ {
+ setSelectedRole(role);
+ setShowDeleteDialog(true);
+ }}
+ >
+
+
+ >
+ )}
+
+
+ ))}
+
+
+ {/* Create Role Modal */}
+
+
+
+ Create New Role
+
+ Create a custom role with specific permissions for your team members.
+
+
+
+
+
+
Role Code *
+
setFormData({ ...formData, code: e.target.value })}
+ />
+
+ Lowercase, no spaces. Used internally to identify the role.
+
+
+
+
+ Role Name *
+ setFormData({ ...formData, name: e.target.value })}
+ />
+
+
+
+ Description
+
+
+
+
Permissions
+
+ Select permissions for this role. You can also add permissions later.
+
+
+ {Object.entries(groupedPermissions).map(([module, perms]) => (
+
+
toggleModule(module)}
+ className="flex items-center w-full text-left font-medium mb-2 hover:text-blue-600"
+ >
+ {expandedModules[module] ? (
+
+ ) : (
+
+ )}
+ {module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
+
+ {expandedModules[module] && (
+
+ {perms.map(perm => (
+
+ {
+ setFormData(prev => ({
+ ...prev,
+ permissions: prev.permissions.includes(perm.code)
+ ? prev.permissions.filter(p => p !== perm.code)
+ : [...prev.permissions, perm.code]
+ }));
+ }}
+ />
+
+ {perm.name}
+ ({perm.code})
+
+
+ ))}
+
+ )}
+
+ ))}
+
+
+
+
+
+ setShowCreateModal(false)}>
+ Cancel
+
+
+ Create Role
+
+
+
+
+
+ {/* Edit Role Modal */}
+
+
+
+ Edit Role
+
+ Update role name and description. Code cannot be changed.
+
+
+
+
+
+ Role Code
+
+
+
+
+ Role Name *
+ setFormData({ ...formData, name: e.target.value })}
+ />
+
+
+
+ Description
+
+
+
+
+ setShowEditModal(false)}>
+ Cancel
+
+
+ Save Changes
+
+
+
+
+
+ {/* Manage Permissions Modal */}
+
+
+
+ Manage Permissions: {selectedRole?.name}
+
+ Select which permissions this role should have.
+
+
+
+
+ {Object.entries(groupedPermissions).map(([module, perms]) => (
+
+
toggleModule(module)}
+ className="flex items-center w-full text-left font-medium text-lg mb-3 hover:text-blue-600"
+ >
+ {expandedModules[module] ? (
+
+ ) : (
+
+ )}
+ {module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
+
+ {expandedModules[module] && (
+
+ {perms.map(perm => (
+
+
togglePermission(perm.code)}
+ />
+
+
+ {perm.name}
+
+
{perm.description}
+
{perm.code}
+
+
+ ))}
+
+ )}
+
+ ))}
+
+
+
+ setShowPermissionsModal(false)}>
+ Cancel
+
+
+ Save Permissions
+
+
+
+
+
+ {/* Delete Confirmation */}
+
+
+
+ Delete Role?
+
+ Are you sure you want to delete the role "{selectedRole?.name}"?
+ This action cannot be undone. Users with this role will need to be reassigned.
+
+
+
+ Cancel
+
+ Delete Role
+
+
+
+
+
+ );
+};
+
+export default AdminRoles;
diff --git a/src/pages/admin/AdminStaff.js b/src/pages/admin/AdminStaff.js
index 9567fcd..e1e035e 100644
--- a/src/pages/admin/AdminStaff.js
+++ b/src/pages/admin/AdminStaff.js
@@ -1,22 +1,31 @@
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 { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
+import CreateStaffDialog from '../../components/CreateStaffDialog';
+import InviteStaffDialog from '../../components/InviteStaffDialog';
+import PendingInvitationsTable from '../../components/PendingInvitationsTable';
import { toast } from 'sonner';
-import { UserCog, Search, Shield } from 'lucide-react';
+import { UserCog, Search, Shield, UserPlus, Mail } from 'lucide-react';
const AdminStaff = () => {
+ const { hasPermission } = useAuth();
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [roleFilter, setRoleFilter] = useState('all');
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
+ const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
+ const [activeTab, setActiveTab] = useState('staff-list');
- // Staff roles (non-guest, non-member)
- const STAFF_ROLES = ['admin'];
+ // Staff roles (non-guest, non-member) - includes all admin-type roles
+ const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
useEffect(() => {
fetchStaff();
@@ -95,12 +104,36 @@ const AdminStaff = () => {
return (
<>
-
- Staff Management
-
-
- Manage internal team members and their roles.
-
+
+
+
+ Staff Management
+
+
+ Manage internal team members and their roles.
+
+
+
+ {hasPermission('users.invite') && (
+ setInviteDialogOpen(true)}
+ className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
+ >
+
+ Invite Staff
+
+ )}
+ {hasPermission('users.create') && (
+ setCreateDialogOpen(true)}
+ className="bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-xl h-12 px-6"
+ >
+
+ Create Staff
+
+ )}
+
+
{/* Stats */}
@@ -131,91 +164,127 @@ const AdminStaff = () => {
- {/* Filters */}
-
-
-
-
- setSearchQuery(e.target.value)}
- className="pl-12 h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
- data-testid="search-staff-input"
- />
-
-
-
-
-
-
- All Roles
- Superadmin
- Admin
- Moderator
- Staff
- Media
-
-
-
-
+ {/* Tabs */}
+
+
+
+
+ Staff Members
+
+
+
+ Pending Invitations
+
+
- {/* Staff List */}
- {loading ? (
-
- ) : filteredUsers.length > 0 ? (
-
- {filteredUsers.map((user) => (
-
-
-
- {/* Avatar */}
-
- {user.first_name?.[0]}{user.last_name?.[0]}
-
-
- {/* Info */}
-
-
-
- {user.first_name} {user.last_name}
-
- {getRoleBadge(user.role)}
- {getStatusBadge(user.status)}
-
-
-
Email: {user.email}
-
Phone: {user.phone}
-
Joined: {new Date(user.created_at).toLocaleDateString()}
- {user.last_login && (
-
Last Login: {new Date(user.last_login).toLocaleDateString()}
- )}
-
-
-
+
+ {/* Filters */}
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-12 h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
+ data-testid="search-staff-input"
+ />
-
- ))}
-
- ) : (
-
-
-
- No Staff Found
-
-
- {searchQuery || roleFilter !== 'all'
- ? 'Try adjusting your filters'
- : 'No staff members yet'}
-
-
- )}
+
+
+
+
+
+ All Roles
+ Superadmin
+ Admin
+ Moderator
+ Staff
+ Media
+
+
+
+
+
+ {/* Staff List */}
+ {loading ? (
+
+ ) : filteredUsers.length > 0 ? (
+
+ {filteredUsers.map((user) => (
+
+
+
+ {/* Avatar */}
+
+ {user.first_name?.[0]}{user.last_name?.[0]}
+
+
+ {/* Info */}
+
+
+
+ {user.first_name} {user.last_name}
+
+ {getRoleBadge(user.role)}
+ {getStatusBadge(user.status)}
+
+
+
Email: {user.email}
+
Phone: {user.phone}
+
Joined: {new Date(user.created_at).toLocaleDateString()}
+ {user.last_login && (
+
Last Login: {new Date(user.last_login).toLocaleDateString()}
+ )}
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+ No Staff Found
+
+
+ {searchQuery || roleFilter !== 'all'
+ ? 'Try adjusting your filters'
+ : 'No staff members yet'}
+
+
+ )}
+
+
+
+
+
+
+
+ {/* Dialogs */}
+
+
+
{
+ // Optionally refresh invitations table
+ setActiveTab('pending-invitations');
+ }}
+ />
>
);
};
diff --git a/src/pages/admin/AdminUserView.js b/src/pages/admin/AdminUserView.js
index 3b8388d..b78e1cf 100644
--- a/src/pages/admin/AdminUserView.js
+++ b/src/pages/admin/AdminUserView.js
@@ -6,6 +6,7 @@ import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
+import ConfirmationDialog from '../../components/ConfirmationDialog';
const AdminUserView = () => {
const { userId } = useParams();
@@ -14,9 +15,14 @@ const AdminUserView = () => {
const [loading, setLoading] = useState(true);
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [resendVerificationLoading, setResendVerificationLoading] = useState(false);
+ const [subscriptions, setSubscriptions] = useState([]);
+ const [subscriptionsLoading, setSubscriptionsLoading] = useState(true);
+ const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
+ const [pendingAction, setPendingAction] = useState(null);
useEffect(() => {
fetchUserProfile();
+ fetchSubscriptions();
}, [userId]);
const fetchUserProfile = async () => {
@@ -31,52 +37,91 @@ const AdminUserView = () => {
}
};
- const handleResetPassword = async () => {
- const confirmed = window.confirm(
- `Reset password for ${user.first_name} ${user.last_name}?\n\n` +
- `A temporary password will be emailed to ${user.email}.\n` +
- `They will be required to change it on next login.`
- );
-
- if (!confirmed) return;
-
- setResetPasswordLoading(true);
-
+ const fetchSubscriptions = async () => {
try {
- await api.put(`/admin/users/${userId}/reset-password`, {
- force_change: true
- });
- toast.success(`Password reset email sent to ${user.email}`);
+ const response = await api.get(`/admin/subscriptions?user_id=${userId}`);
+ setSubscriptions(response.data);
} catch (error) {
- const errorMessage = error.response?.data?.detail || 'Failed to reset password';
- toast.error(errorMessage);
+ console.error('Failed to fetch subscriptions:', error);
} finally {
- setResetPasswordLoading(false);
+ setSubscriptionsLoading(false);
}
};
- const handleResendVerification = async () => {
- const confirmed = window.confirm(
- `Resend verification email to ${user.email}?`
- );
+ const handleResetPasswordRequest = () => {
+ setPendingAction({ type: 'reset_password' });
+ setConfirmDialogOpen(true);
+ };
- if (!confirmed) return;
+ const handleResendVerificationRequest = () => {
+ setPendingAction({ type: 'resend_verification' });
+ setConfirmDialogOpen(true);
+ };
- setResendVerificationLoading(true);
+ const confirmAction = async () => {
+ if (!pendingAction) return;
- try {
- await api.post(`/admin/users/${userId}/resend-verification`);
- toast.success(`Verification email sent to ${user.email}`);
- // Refresh user data to get updated email_verified status if changed
- await fetchUserProfile();
- } catch (error) {
- const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
- toast.error(errorMessage);
- } finally {
- setResendVerificationLoading(false);
+ const { type } = pendingAction;
+ setConfirmDialogOpen(false);
+
+ if (type === 'reset_password') {
+ setResetPasswordLoading(true);
+ try {
+ await api.put(`/admin/users/${userId}/reset-password`, {
+ force_change: true
+ });
+ toast.success(`Password reset email sent to ${user.email}`);
+ } catch (error) {
+ const errorMessage = error.response?.data?.detail || 'Failed to reset password';
+ toast.error(errorMessage);
+ } finally {
+ setResetPasswordLoading(false);
+ setPendingAction(null);
+ }
+ } else if (type === 'resend_verification') {
+ setResendVerificationLoading(true);
+ try {
+ await api.post(`/admin/users/${userId}/resend-verification`);
+ toast.success(`Verification email sent to ${user.email}`);
+ // Refresh user data to get updated email_verified status if changed
+ await fetchUserProfile();
+ } catch (error) {
+ const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
+ toast.error(errorMessage);
+ } finally {
+ setResendVerificationLoading(false);
+ setPendingAction(null);
+ }
}
};
+ const getActionMessage = () => {
+ if (!pendingAction || !user) return {};
+
+ const { type } = pendingAction;
+ const userName = `${user.first_name} ${user.last_name}`;
+
+ if (type === 'reset_password') {
+ return {
+ title: 'Reset Password?',
+ description: `This will send a temporary password to ${user.email}. ${userName} will be required to change it on their next login.`,
+ variant: 'warning',
+ confirmText: 'Yes, Reset Password',
+ };
+ }
+
+ if (type === 'resend_verification') {
+ return {
+ title: 'Resend Verification Email?',
+ description: `This will send a new verification email to ${user.email}. ${userName} will need to click the link to verify their email address.`,
+ variant: 'info',
+ confirmText: 'Yes, Resend Email',
+ };
+ }
+
+ return {};
+ };
+
if (loading) return Loading...
;
if (!user) return null;
@@ -141,7 +186,7 @@ const AdminUserView = () => {
{
{!user.email_verified && (
{
Subscription Information
- {/* TODO: Fetch and display subscription data */}
- Subscription details coming soon...
+
+ {subscriptionsLoading ? (
+ Loading subscriptions...
+ ) : subscriptions.length === 0 ? (
+ No subscriptions found for this member.
+ ) : (
+
+ {subscriptions.map((sub) => (
+
+
+
+
+ {sub.plan.name}
+
+
+ {sub.plan.billing_cycle}
+
+
+
+ {sub.status}
+
+
+
+
+
+
Start Date
+
+ {new Date(sub.start_date).toLocaleDateString()}
+
+
+ {sub.end_date && (
+
+
End Date
+
+ {new Date(sub.end_date).toLocaleDateString()}
+
+
+ )}
+
+
Base Amount
+
+ ${(sub.base_subscription_cents / 100).toFixed(2)}
+
+
+ {sub.donation_cents > 0 && (
+
+
Donation
+
+ ${(sub.donation_cents / 100).toFixed(2)}
+
+
+ )}
+
+
Total Paid
+
+ ${(sub.amount_paid_cents / 100).toFixed(2)}
+
+
+ {sub.payment_method && (
+
+
Payment Method
+
+ {sub.payment_method}
+
+
+ )}
+ {sub.stripe_subscription_id && (
+
+
Stripe Subscription ID
+
+ {sub.stripe_subscription_id}
+
+
+ )}
+
+
+ ))}
+
+ )}
)}
+
+ {/* Admin Action Confirmation Dialog */}
+
>
);
};
diff --git a/src/pages/admin/AdminUsers.js b/src/pages/admin/AdminUsers.js
deleted file mode 100644
index 0d7b49e..0000000
--- a/src/pages/admin/AdminUsers.js
+++ /dev/null
@@ -1,214 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { useNavigate, useLocation } from 'react-router-dom';
-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 { toast } from 'sonner';
-import { Users, Search, CheckCircle, Clock, Mail, Eye } from 'lucide-react';
-
-const AdminUsers = () => {
- const navigate = useNavigate();
- const location = useLocation();
- const [users, setUsers] = useState([]);
- const [filteredUsers, setFilteredUsers] = useState([]);
- const [loading, setLoading] = useState(true);
- const [searchQuery, setSearchQuery] = useState('');
- const [statusFilter, setStatusFilter] = useState('all');
- const [resendingUserId, setResendingUserId] = useState(null);
-
- useEffect(() => {
- fetchUsers();
- }, []);
-
- useEffect(() => {
- filterUsers();
- }, [users, searchQuery, statusFilter]);
-
- const fetchUsers = async () => {
- try {
- const response = await api.get('/admin/users');
- setUsers(response.data);
- } catch (error) {
- toast.error('Failed to fetch users');
- } finally {
- setLoading(false);
- }
- };
-
- const filterUsers = () => {
- let filtered = users;
-
- if (statusFilter && statusFilter !== 'all') {
- filtered = filtered.filter(user => user.status === statusFilter);
- }
-
- if (searchQuery) {
- const query = searchQuery.toLowerCase();
- filtered = filtered.filter(user =>
- user.first_name.toLowerCase().includes(query) ||
- user.last_name.toLowerCase().includes(query) ||
- user.email.toLowerCase().includes(query)
- );
- }
-
- setFilteredUsers(filtered);
- };
-
- 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' },
- 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' }
- };
-
- const statusConfig = config[status] || config.inactive;
- return (
-
- {statusConfig.label}
-
- );
- };
-
- const handleAdminResendVerification = async (userId, userEmail) => {
- const confirmed = window.confirm(
- `Resend verification email to ${userEmail}?`
- );
-
- if (!confirmed) return;
-
- setResendingUserId(userId);
- try {
- await api.post(`/admin/users/${userId}/resend-verification`);
- toast.success(`Verification email sent to ${userEmail}`);
- } catch (error) {
- const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
- toast.error(errorMessage);
- } finally {
- setResendingUserId(null);
- }
- };
-
- return (
- <>
-
-
- User Management
-
-
- View and manage all registered users.
-
-
-
- {/* Filters */}
-
-
-
-
- setSearchQuery(e.target.value)}
- className="pl-12 h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
- data-testid="search-users-input"
- />
-
-
-
-
-
-
- All Statuses
- Pending Email
- Pending Approval
- Pre-Approved
- Payment Pending
- Active
- Inactive
-
-
-
-
-
- {/* Users List */}
- {loading ? (
-
- ) : filteredUsers.length > 0 ? (
-
- {filteredUsers.map((user) => (
-
-
-
-
-
- {user.first_name} {user.last_name}
-
- {getStatusBadge(user.status)}
-
-
-
Email: {user.email}
-
Phone: {user.phone}
-
Role: {user.role}
-
Joined: {new Date(user.created_at).toLocaleDateString()}
- {user.referred_by_member_name && (
-
Referred by: {user.referred_by_member_name}
- )}
-
-
-
- navigate(`/admin/users/${user.id}`)}
- variant="ghost"
- size="sm"
- className="text-[#664fa3] hover:text-[#422268]"
- >
-
- View
-
-
- {!user.email_verified && (
- handleAdminResendVerification(user.id, user.email)}
- disabled={resendingUserId === user.id}
- variant="ghost"
- size="sm"
- className="text-[#ff9e77] hover:text-[#664fa3]"
- >
-
- {resendingUserId === user.id ? 'Sending...' : 'Resend Verification'}
-
- )}
-
-
-
- ))}
-
- ) : (
-
-
-
- No Users Found
-
-
- {searchQuery || statusFilter !== 'all'
- ? 'Try adjusting your filters'
- : 'No users registered yet'}
-
-
- )}
- >
- );
-};
-
-export default AdminUsers;
diff --git a/src/pages/admin/AdminApprovals.js b/src/pages/admin/AdminValidations.js
similarity index 81%
rename from src/pages/admin/AdminApprovals.js
rename to src/pages/admin/AdminValidations.js
index 3990300..febf5ae 100644
--- a/src/pages/admin/AdminApprovals.js
+++ b/src/pages/admin/AdminValidations.js
@@ -31,14 +31,17 @@ import {
import { toast } from 'sonner';
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
+import ConfirmationDialog from '../../components/ConfirmationDialog';
-const AdminApprovals = () => {
+const AdminValidations = () => {
const [pendingUsers, setPendingUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(null);
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
+ const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
+ const [pendingAction, setPendingAction] = useState(null);
// Filtering state
const [searchQuery, setSearchQuery] = useState('');
@@ -68,7 +71,7 @@ const AdminApprovals = () => {
try {
const response = await api.get('/admin/users');
const pending = response.data.filter(user =>
- ['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(user.status)
+ ['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(user.status)
);
setPendingUsers(pending);
} catch (error) {
@@ -120,37 +123,65 @@ const AdminApprovals = () => {
setFilteredUsers(filtered);
};
- const handleApprove = async (userId) => {
- setActionLoading(userId);
+ const handleValidateRequest = (user) => {
+ setPendingAction({ type: 'validate', user });
+ setConfirmDialogOpen(true);
+ };
+
+ const handleBypassAndValidateRequest = (user) => {
+ setPendingAction({ type: 'bypass_and_validate', user });
+ setConfirmDialogOpen(true);
+ };
+
+ const confirmAction = async () => {
+ if (!pendingAction) return;
+
+ const { type, user } = pendingAction;
+ setActionLoading(user.id);
+ setConfirmDialogOpen(false);
+
try {
- await api.put(`/admin/users/${userId}/approve`);
- toast.success('User validated and approved! Payment email sent.');
+ if (type === 'validate') {
+ await api.put(`/admin/users/${user.id}/validate`);
+ toast.success('User validated! Payment email sent.');
+ } else if (type === 'bypass_and_validate') {
+ await api.put(`/admin/users/${user.id}/validate?bypass_email_verification=true`);
+ toast.success('User email verified and validated! Payment email sent.');
+ }
fetchPendingUsers();
} catch (error) {
- toast.error('Failed to approve user');
+ toast.error(error.response?.data?.detail || 'Failed to validate user');
} finally {
setActionLoading(null);
+ setPendingAction(null);
}
};
- const handleBypassAndApprove = async (userId) => {
- if (!window.confirm(
- 'This will bypass email verification and approve the user. ' +
- 'Are you sure you want to proceed?'
- )) {
- return;
+ const getActionMessage = () => {
+ if (!pendingAction) return {};
+
+ const { type, user } = pendingAction;
+ const userName = `${user.first_name} ${user.last_name}`;
+
+ if (type === 'validate') {
+ return {
+ title: 'Validate User?',
+ description: `This will validate ${userName} and send them a payment link email. They will be able to complete payment and become an active member.`,
+ variant: 'success',
+ confirmText: 'Yes, Validate User',
+ };
}
- setActionLoading(userId);
- try {
- await api.put(`/admin/users/${userId}/approve?bypass_email_verification=true`);
- toast.success('User email verified and approved! Payment email sent.');
- fetchPendingUsers();
- } catch (error) {
- toast.error(error.response?.data?.detail || 'Failed to approve user');
- } finally {
- setActionLoading(null);
+ if (type === 'bypass_and_validate') {
+ return {
+ title: 'Bypass Email & Validate User?',
+ description: `This will bypass email verification for ${userName} and validate them immediately. A payment link email will be sent. Use this only if you've confirmed their email through other means.`,
+ variant: 'warning',
+ confirmText: 'Yes, Bypass & Validate',
+ };
}
+
+ return {};
};
const handleActivatePayment = (user) => {
@@ -165,8 +196,8 @@ const AdminApprovals = () => {
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' },
- pending_approval: { label: 'Pending', 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' }
};
@@ -206,10 +237,10 @@ const AdminApprovals = () => {
{/* Header */}
- Approval Queue
+ Validation Queue
- Review and approve pending membership applications.
+ Review and validate pending membership applications.
@@ -229,15 +260,15 @@ const AdminApprovals = () => {
-
Pending Approval
+
Pending Validation
- {pendingUsers.filter(u => u.status === 'pending_approval').length}
+ {pendingUsers.filter(u => u.status === 'pending_validation').length}
-
Pre-Approved
+
Pre-Validated
- {pendingUsers.filter(u => u.status === 'pre_approved').length}
+ {pendingUsers.filter(u => u.status === 'pre_validated').length}
@@ -269,8 +300,8 @@ const AdminApprovals = () => {
All Statuses
Awaiting Email
- Pending Approval
- Pre-Approved
+ Pending Validation
+ Pre-Validated
@@ -330,12 +361,12 @@ const AdminApprovals = () => {
{user.status === 'pending_email' ? (
handleBypassAndApprove(user.id)}
+ onClick={() => handleBypassAndValidateRequest(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
>
- {actionLoading === user.id ? 'Approving...' : 'Bypass & Approve'}
+ {actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
) : user.status === 'payment_pending' ? (
{
) : (
handleApprove(user.id)}
+ onClick={() => handleValidateRequest(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
>
- {actionLoading === user.id ? 'Validating...' : 'Approve'}
+ {actionLoading === user.id ? 'Validating...' : 'Validate'}
)}
@@ -445,7 +476,7 @@ const AdminApprovals = () => {
- No Pending Approvals
+ No Pending Validations
{searchQuery || statusFilter !== 'all'
@@ -462,8 +493,17 @@ const AdminApprovals = () => {
user={selectedUserForPayment}
onSuccess={handlePaymentSuccess}
/>
+
+ {/* Validation Confirmation Dialog */}
+
>
);
};
-export default AdminApprovals;
+export default AdminValidations;
diff --git a/src/pages/members/MembersDirectory.js b/src/pages/members/MembersDirectory.js
index a8da76d..cc98ebd 100644
--- a/src/pages/members/MembersDirectory.js
+++ b/src/pages/members/MembersDirectory.js
@@ -13,7 +13,7 @@ import {
DialogHeader,
DialogTitle,
} from '../../components/ui/dialog';
-import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react';
+import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react';
import { useToast } from '../../hooks/use-toast';
const MembersDirectory = () => {
@@ -139,6 +139,17 @@ const MembersDirectory = () => {
)}
+ {/* Member Since */}
+
+
+
+ Member since {new Date(member.member_since || member.created_at).toLocaleDateString('en-US', {
+ month: 'long',
+ year: 'numeric'
+ })}
+
+
+
{/* Contact Information */}
{member.directory_email && (