2 Commits
main ... main

Author SHA1 Message Date
Koncept Kit
9ed778db1c RBAC, Permissions, and Export/Import 2025-12-16 20:04:00 +07:00
Koncept Kit
02e38e1050 Member Navbar Responsive update 2025-12-13 13:39:54 +07:00
30 changed files with 4790 additions and 491 deletions

View File

@@ -17,12 +17,13 @@ import BecomeMember from './pages/BecomeMember';
import PaymentSuccess from './pages/PaymentSuccess'; import PaymentSuccess from './pages/PaymentSuccess';
import PaymentCancel from './pages/PaymentCancel'; import PaymentCancel from './pages/PaymentCancel';
import AdminDashboard from './pages/admin/AdminDashboard'; import AdminDashboard from './pages/admin/AdminDashboard';
import AdminUsers from './pages/admin/AdminUsers';
import AdminUserView from './pages/admin/AdminUserView'; import AdminUserView from './pages/admin/AdminUserView';
import AdminStaff from './pages/admin/AdminStaff'; import AdminStaff from './pages/admin/AdminStaff';
import AdminMembers from './pages/admin/AdminMembers'; 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 AdminEvents from './pages/admin/AdminEvents';
import AdminApprovals from './pages/admin/AdminApprovals'; import AdminValidations from './pages/admin/AdminValidations';
import AdminPlans from './pages/admin/AdminPlans'; import AdminPlans from './pages/admin/AdminPlans';
import AdminSubscriptions from './pages/admin/AdminSubscriptions'; import AdminSubscriptions from './pages/admin/AdminSubscriptions';
import AdminLayout from './layouts/AdminLayout'; import AdminLayout from './layouts/AdminLayout';
@@ -46,6 +47,8 @@ import Donate from './pages/Donate';
import DonationSuccess from './pages/DonationSuccess'; import DonationSuccess from './pages/DonationSuccess';
import Resources from './pages/Resources'; import Resources from './pages/Resources';
import ContactUs from './pages/ContactUs'; import ContactUs from './pages/ContactUs';
import TermsOfService from './pages/TermsOfService';
import PrivacyPolicy from './pages/PrivacyPolicy';
const PrivateRoute = ({ children, adminOnly = false }) => { const PrivateRoute = ({ children, adminOnly = false }) => {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -58,7 +61,7 @@ const PrivateRoute = ({ children, adminOnly = false }) => {
return <Navigate to="/login" />; return <Navigate to="/login" />;
} }
if (adminOnly && user.role !== 'admin') { if (adminOnly && !['admin', 'superadmin'].includes(user.role)) {
return <Navigate to="/dashboard" />; return <Navigate to="/dashboard" />;
} }
@@ -105,6 +108,10 @@ function App() {
<Route path="/donate" element={<Donate />} /> <Route path="/donate" element={<Donate />} />
<Route path="/donation-success" element={<DonationSuccess />} /> <Route path="/donation-success" element={<DonationSuccess />} />
{/* Legal Pages - Public Access */}
<Route path="/terms-of-service" element={<TermsOfService />} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
<Route path="/dashboard" element={ <Route path="/dashboard" element={
<PrivateRoute> <PrivateRoute>
<Dashboard /> <Dashboard />
@@ -189,13 +196,6 @@ function App() {
</AdminLayout> </AdminLayout>
</PrivateRoute> </PrivateRoute>
} /> } />
<Route path="/admin/users" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminUsers />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/users/:userId" element={ <Route path="/admin/users/:userId" element={
<PrivateRoute adminOnly> <PrivateRoute adminOnly>
<AdminLayout> <AdminLayout>
@@ -210,10 +210,10 @@ function App() {
</AdminLayout> </AdminLayout>
</PrivateRoute> </PrivateRoute>
} /> } />
<Route path="/admin/approvals" element={ <Route path="/admin/validations" element={
<PrivateRoute adminOnly> <PrivateRoute adminOnly>
<AdminLayout> <AdminLayout>
<AdminApprovals /> <AdminValidations />
</AdminLayout> </AdminLayout>
</PrivateRoute> </PrivateRoute>
} /> } />
@@ -259,6 +259,13 @@ function App() {
</AdminLayout> </AdminLayout>
</PrivateRoute> </PrivateRoute>
} /> } />
<Route path="/admin/permissions" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminRoles />
</AdminLayout>
</PrivateRoute>
} />
</Routes> </Routes>
<Toaster position="top-right" /> <Toaster position="top-right" />
</BrowserRouter> </BrowserRouter>

View File

@@ -39,7 +39,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
try { try {
const response = await api.get('/admin/users'); const response = await api.get('/admin/users');
const pending = response.data.filter(u => const pending = response.data.filter(u =>
['pending_approval', 'pre_approved'].includes(u.status) ['pending_validation', 'pre_validated'].includes(u.status)
); );
setPendingCount(pending.length); setPendingCount(pending.length);
} catch (error) { } catch (error) {
@@ -105,9 +105,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
disabled: false disabled: false
}, },
{ {
name: 'Approvals', name: 'Validations',
icon: CheckCircle, icon: CheckCircle,
path: '/admin/approvals', path: '/admin/validations',
disabled: false, disabled: false,
badge: pendingCount badge: pendingCount
}, },
@@ -154,9 +154,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
disabled: false disabled: false
}, },
{ {
name: 'Roles', name: 'Permissions',
icon: Shield, icon: Shield,
path: '/admin/roles', path: '/admin/permissions',
disabled: false, disabled: false,
superadminOnly: true superadminOnly: true
} }

View File

@@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="bg-white rounded-2xl border border-[#ddd8eb] p-0 overflow-hidden max-w-md">
<AlertDialogHeader className="p-6 pb-4">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-full bg-[#F8F7FB] ${config.iconColor}`}>
<Icon className="h-6 w-6" />
</div>
<div className="flex-1">
<AlertDialogTitle
className="text-xl font-semibold text-[#422268] mb-2"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{title}
</AlertDialogTitle>
<AlertDialogDescription
className="text-[#664fa3] text-sm leading-relaxed"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{description}
</AlertDialogDescription>
</div>
</div>
</AlertDialogHeader>
<AlertDialogFooter className="p-6 pt-4 bg-[#F8F7FB] flex-row gap-3 justify-end">
<AlertDialogCancel
className="border-2 border-[#ddd8eb] text-[#664fa3] hover:bg-white rounded-full px-6"
disabled={loading}
>
{cancelText}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
className={config.confirmButtonClass}
disabled={loading}
>
{loading ? 'Processing...' : confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default ConfirmationDialog;

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] rounded-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<UserPlus className="h-6 w-6" />
Create Member
</DialogTitle>
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Create a new member account with direct login access. Member will be created immediately.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
{/* Email & Password Row */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="email" className="text-[#422268]">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="member@example.com"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="password" className="text-[#422268]">
Password <span className="text-red-500">*</span>
</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Minimum 8 characters"
/>
{errors.password && (
<p className="text-sm text-red-500">{errors.password}</p>
)}
</div>
</div>
{/* Name Row */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="first_name" className="text-[#422268]">
First Name <span className="text-red-500">*</span>
</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="John"
/>
{errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="last_name" className="text-[#422268]">
Last Name <span className="text-red-500">*</span>
</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Doe"
/>
{errors.last_name && (
<p className="text-sm text-red-500">{errors.last_name}</p>
)}
</div>
</div>
{/* Phone */}
<div className="grid gap-2">
<Label htmlFor="phone" className="text-[#422268]">
Phone <span className="text-red-500">*</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="(555) 123-4567"
/>
{errors.phone && (
<p className="text-sm text-red-500">{errors.phone}</p>
)}
</div>
{/* Address */}
<div className="grid gap-2">
<Label htmlFor="address" className="text-[#422268]">
Address
</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="123 Main St"
/>
</div>
{/* City, State, Zipcode Row */}
<div className="grid md:grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="city" className="text-[#422268]">City</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleChange('city', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="San Francisco"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="state" className="text-[#422268]">State</Label>
<Input
id="state"
value={formData.state}
onChange={(e) => handleChange('state', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="CA"
maxLength={2}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="zipcode" className="text-[#422268]">Zipcode</Label>
<Input
id="zipcode"
value={formData.zipcode}
onChange={(e) => handleChange('zipcode', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="94102"
/>
</div>
</div>
{/* Dates Row */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="date_of_birth" className="text-[#422268]">Date of Birth</Label>
<Input
id="date_of_birth"
type="date"
value={formData.date_of_birth}
onChange={(e) => handleChange('date_of_birth', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="member_since" className="text-[#422268]">Member Since</Label>
<Input
id="member_since"
type="date"
value={formData.member_since}
onChange={(e) => handleChange('member_since', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/>
</div>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<UserPlus className="h-4 w-4 mr-2" />
Create Member
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default CreateMemberDialog;

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] rounded-2xl">
<DialogHeader>
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<UserPlus className="h-6 w-6" />
Create Staff Member
</DialogTitle>
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Create a new staff account with direct login access. User will be created immediately.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
{/* Email */}
<div className="grid gap-2">
<Label htmlFor="email" className="text-[#422268]">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="staff@example.com"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
{/* Password */}
<div className="grid gap-2">
<Label htmlFor="password" className="text-[#422268]">
Password <span className="text-red-500">*</span>
</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Minimum 8 characters"
/>
{errors.password && (
<p className="text-sm text-red-500">{errors.password}</p>
)}
</div>
{/* First Name */}
<div className="grid gap-2">
<Label htmlFor="first_name" className="text-[#422268]">
First Name <span className="text-red-500">*</span>
</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="John"
/>
{errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p>
)}
</div>
{/* Last Name */}
<div className="grid gap-2">
<Label htmlFor="last_name" className="text-[#422268]">
Last Name <span className="text-red-500">*</span>
</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Doe"
/>
{errors.last_name && (
<p className="text-sm text-red-500">{errors.last_name}</p>
)}
</div>
{/* Phone */}
<div className="grid gap-2">
<Label htmlFor="phone" className="text-[#422268]">
Phone <span className="text-red-500">*</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="(555) 123-4567"
/>
{errors.phone && (
<p className="text-sm text-red-500">{errors.phone}</p>
)}
</div>
{/* Role */}
<div className="grid gap-2">
<Label htmlFor="role" className="text-[#422268]">
Role <span className="text-red-500">*</span>
</Label>
<Select value={formData.role} onValueChange={(value) => handleChange('role', value)}>
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="superadmin">Superadmin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<UserPlus className="h-4 w-4 mr-2" />
Create Staff
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default CreateStaffDialog;

View File

@@ -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 <CheckCircle className="h-6 w-6 text-green-500" />;
} else if (status === 'partial') {
return <AlertCircle className="h-6 w-6 text-orange-500" />;
} else {
return <XCircle className="h-6 w-6 text-red-500" />;
}
};
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 (
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full`}>
{statusConfig.label}
</Badge>
);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[800px] rounded-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Upload className="h-6 w-6" />
{importResult ? 'Import Results' : 'Import Members from CSV'}
</DialogTitle>
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{importResult
? 'Review the import results below'
: 'Upload a CSV file to bulk import members. Ensure the CSV has the required columns.'}
</DialogDescription>
</DialogHeader>
{!importResult ? (
// Upload Form
<div className="grid gap-6 py-4">
{/* CSV Format Instructions */}
<Alert className="border-[#664fa3] bg-[#F9F8FB]">
<AlertDescription className="text-sm text-[#422268]">
<strong>Required columns:</strong> Email, First Name, Last Name, Phone, Role
<br />
<strong>Optional columns:</strong> Status, Address, City, State, Zipcode, Date of Birth, Member Since
<br />
<strong>Date format:</strong> YYYY-MM-DD (e.g., 2024-01-15)
</AlertDescription>
</Alert>
{/* File Upload Area */}
<div
className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors ${
dragActive
? 'border-[#664fa3] bg-[#F9F8FB]'
: 'border-[#ddd8eb] hover:border-[#664fa3] hover:bg-[#F9F8FB]'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{file ? (
<div className="flex flex-col items-center gap-4">
<FileUp className="h-16 w-16 text-[#81B29A]" />
<div>
<p className="text-lg font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{file.name}
</p>
<p className="text-sm text-[#664fa3]">
{(file.size / 1024).toFixed(2)} KB
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFile(null)}
className="rounded-xl"
>
Remove File
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<Upload className="h-16 w-16 text-[#ddd8eb]" />
<div>
<p className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Drag and drop your CSV file here
</p>
<p className="text-sm text-[#664fa3] mb-4">or</p>
<Label htmlFor="file-upload">
<Button variant="outline" className="rounded-xl cursor-pointer" asChild>
<span>Browse Files</span>
</Button>
</Label>
<input
id="file-upload"
type="file"
accept=".csv"
onChange={handleFileInput}
className="hidden"
/>
</div>
</div>
)}
</div>
{/* Options */}
<div className="flex items-center gap-3 p-4 bg-[#F9F8FB] rounded-xl">
<Checkbox
checked={updateExisting}
onCheckedChange={setUpdateExisting}
id="update-existing"
className="h-5 w-5 border-2 border-[#664fa3] data-[state=checked]:bg-[#664fa3]"
/>
<Label htmlFor="update-existing" className="text-[#422268] cursor-pointer">
Update existing members (if email already exists)
</Label>
</div>
</div>
) : (
// Import Results
<div className="grid gap-6 py-4">
{/* Summary Cards */}
<div className="grid md:grid-cols-4 gap-4">
<div className="p-4 bg-white rounded-xl border border-[#ddd8eb] text-center">
<p className="text-sm text-[#664fa3] mb-1">Total Rows</p>
<p className="text-2xl font-semibold text-[#422268]">{importResult.total_rows}</p>
</div>
<div className="p-4 bg-green-50 rounded-xl border border-green-200 text-center">
<p className="text-sm text-green-700 mb-1">Successful</p>
<p className="text-2xl font-semibold text-green-600">{importResult.successful_rows}</p>
</div>
<div className="p-4 bg-red-50 rounded-xl border border-red-200 text-center">
<p className="text-sm text-red-700 mb-1">Failed</p>
<p className="text-2xl font-semibold text-red-600">{importResult.failed_rows}</p>
</div>
<div className="p-4 bg-white rounded-xl border border-[#ddd8eb] flex items-center justify-center gap-2">
{getStatusIcon(importResult.status)}
{getStatusBadge(importResult.status)}
</div>
</div>
{/* Errors Table */}
{importResult.errors && importResult.errors.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Errors ({importResult.errors.length} {importResult.errors.length === 10 ? '- showing first 10' : ''})
</h3>
<div className="border border-[#ddd8eb] rounded-xl overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-[#DDD8EB] hover:bg-[#DDD8EB]">
<TableHead className="text-[#422268] font-semibold">Row</TableHead>
<TableHead className="text-[#422268] font-semibold">Email</TableHead>
<TableHead className="text-[#422268] font-semibold">Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{importResult.errors.map((error, idx) => (
<TableRow key={idx} className="hover:bg-[#F9F8FB]">
<TableCell className="font-medium text-[#422268]">{error.row}</TableCell>
<TableCell className="text-[#664fa3]">{error.email}</TableCell>
<TableCell className="text-red-600 text-sm">{error.error}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
)}
<DialogFooter>
{!importResult ? (
<>
<Button
variant="outline"
onClick={handleClose}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
disabled={loading || !file}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Importing...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Import Members
</>
)}
</Button>
</>
) : (
<Button
onClick={handleClose}
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
>
Done
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ImportMembersDialog;

View File

@@ -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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px] rounded-2xl">
<DialogHeader>
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-6 w-6" />
{invitationUrl ? 'Invitation Sent' : 'Invite Staff Member'}
</DialogTitle>
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{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.'}
</DialogDescription>
</DialogHeader>
{invitationUrl ? (
// Show invitation URL after successful send
<div className="py-4">
<Label className="text-[#422268] mb-2 block">Invitation Link (expires in 7 days)</Label>
<div className="flex gap-2">
<Input
value={invitationUrl}
readOnly
className="rounded-xl border-2 border-[#ddd8eb] bg-gray-50"
/>
<Button
onClick={copyToClipboard}
className="rounded-xl bg-[#664fa3] hover:bg-[#422268] text-white flex-shrink-0"
>
{copied ? (
<>
<Check className="h-4 w-4 mr-2" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Copy
</>
)}
</Button>
</div>
</div>
) : (
// Show invitation form
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
{/* Email */}
<div className="grid gap-2">
<Label htmlFor="email" className="text-[#422268]">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="staff@example.com"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
{/* First Name (Optional) */}
<div className="grid gap-2">
<Label htmlFor="first_name" className="text-[#422268]">
First Name <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="John"
/>
</div>
{/* Last Name (Optional) */}
<div className="grid gap-2">
<Label htmlFor="last_name" className="text-[#422268]">
Last Name <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Doe"
/>
</div>
{/* Phone (Optional) */}
<div className="grid gap-2">
<Label htmlFor="phone" className="text-[#422268]">
Phone <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="(555) 123-4567"
/>
</div>
{/* Role */}
<div className="grid gap-2">
<Label htmlFor="role" className="text-[#422268]">
Role <span className="text-red-500">*</span>
</Label>
<Select
value={formData.role}
onValueChange={(value) => handleChange('role', value)}
disabled={loadingRoles}
>
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
<SelectValue placeholder={loadingRoles ? "Loading roles..." : "Select a role"} />
</SelectTrigger>
<SelectContent>
{roles.map(role => (
<SelectItem key={role.id} value={role.code}>
<div className="flex items-center gap-2">
<span>{role.name}</span>
{role.is_system_role && (
<span className="text-xs text-gray-500">(System)</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{roles.length > 0 && (
<p className="text-xs text-gray-500">
{roles.find(r => r.code === formData.role)?.description || ''}
</p>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<Mail className="h-4 w-4 mr-2" />
Send Invitation
</>
)}
</Button>
</DialogFooter>
</form>
)}
{invitationUrl && (
<DialogFooter>
<Button
onClick={handleClose}
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
>
Done
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
};
export default InviteStaffDialog;

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ChevronDown } from 'lucide-react'; import { ChevronDown, Menu, X } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -13,6 +13,7 @@ import {
const Navbar = () => { const Navbar = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// LOAF logo (local) // LOAF logo (local)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
@@ -24,14 +25,14 @@ const Navbar = () => {
return ( return (
<> <>
{/* Top Header - Member Actions */} {/* Top Header - Member Actions (Desktop Only) */}
<header className="bg-gradient-to-r from-[#644c9f] to-[#48286e] px-4 sm:px-8 md:px-16 py-4 flex justify-end items-center gap-4 sm:gap-6"> <header className="hidden lg:flex bg-gradient-to-r from-[#644c9f] to-[#48286e] px-4 sm:px-8 md:px-16 py-4 justify-end items-center gap-4 sm:gap-6">
{user && ( {user && (
<span className="text-white text-base font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}> <span className="text-white text-base font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>
Welcome, {user.first_name} Welcome, {user.first_name}
</span> </span>
)} )}
{user?.role === 'admin' && ( {(user?.role === 'admin' || user?.role === 'superadmin') && (
<Link to="/admin"> <Link to="/admin">
<button <button
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer" className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
@@ -65,7 +66,9 @@ const Navbar = () => {
<Link to="/dashboard"> <Link to="/dashboard">
<img src={loafLogo} alt="LOAF Logo" className="h-16 w-16 sm:h-20 sm:w-20 md:h-28 md:w-28 object-contain" /> <img src={loafLogo} alt="LOAF Logo" className="h-16 w-16 sm:h-20 sm:w-20 md:h-28 md:w-28 object-contain" />
</Link> </Link>
<nav className="flex gap-6 sm:gap-8 md:gap-10 items-center">
{/* Desktop Navigation */}
<nav className="hidden lg:flex gap-6 sm:gap-8 md:gap-10 items-center">
<Link <Link
to="/" to="/"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity" className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
@@ -154,7 +157,211 @@ const Navbar = () => {
Profile Profile
</Link> </Link>
</nav> </nav>
{/* Mobile Hamburger Button */}
<button
onClick={() => setIsMobileMenuOpen(true)}
className="lg:hidden p-2 text-white hover:bg-white/10 rounded-lg transition-colors"
aria-label="Open menu"
>
<Menu className="h-6 w-6" />
</button>
</header> </header>
{/* Mobile Menu Drawer */}
{isMobileMenuOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setIsMobileMenuOpen(false)}
/>
{/* Drawer */}
<div className="fixed right-0 top-0 h-full w-[280px] bg-gradient-to-b from-[#664fa3] to-[#48286e] shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-white/20">
<div className="flex items-center gap-3">
<img src={loafLogo} alt="LOAF" className="h-10 w-10 object-contain" />
<span className="text-white font-semibold text-lg" style={{ fontFamily: "'Poppins', sans-serif" }}>
LOAF
</span>
</div>
<button
onClick={() => setIsMobileMenuOpen(false)}
className="p-2 text-white hover:bg-white/10 rounded-lg transition-colors"
aria-label="Close menu"
>
<X className="h-6 w-6" />
</button>
</div>
{/* User Info */}
{user && (
<div className="px-6 py-4 border-b border-white/20">
<p className="text-white text-sm opacity-90" style={{ fontFamily: "'Poppins', sans-serif" }}>
Welcome,
</p>
<p className="text-white font-semibold text-base" style={{ fontFamily: "'Poppins', sans-serif" }}>
{user.first_name} {user.last_name}
</p>
</div>
)}
{/* Navigation Links */}
<nav className="flex-1 overflow-y-auto py-6 px-4">
<div className="space-y-2">
<Link
to="/"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Home
</Link>
{/* About Us Section */}
<div className="space-y-1">
<p className="px-4 py-2 text-white/70 text-sm font-semibold uppercase tracking-wider" style={{ fontFamily: "'Poppins', sans-serif" }}>
About Us
</p>
<Link
to="/about/history"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
History
</Link>
<Link
to="/about/mission-values"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Mission and Values
</Link>
<Link
to="/about/board"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Board of Directors
</Link>
</div>
<Link
to="/dashboard"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Dashboard
</Link>
<Link
to="/events"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
data-testid="mobile-events-nav-button"
>
Events
</Link>
<Link
to="/members/calendar"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Calendar
</Link>
<Link
to="/members/directory"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Directory
</Link>
<Link
to="/members/gallery"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Gallery
</Link>
<Link
to="/members/newsletters"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Documents
</Link>
<Link
to="/profile"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
data-testid="mobile-profile-nav-button"
>
Profile
</Link>
</div>
</nav>
{/* Footer Actions */}
<div className="p-4 border-t border-white/20 space-y-3">
{(user?.role === 'admin' || user?.role === 'superadmin') && (
<Link
to="/admin"
onClick={() => setIsMobileMenuOpen(false)}
>
<Button
className="w-full bg-white/20 hover:bg-white/30 text-white rounded-lg"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Admin Panel
</Button>
</Link>
)}
<Link
to="/donate"
onClick={() => setIsMobileMenuOpen(false)}
>
<Button
className="w-full bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-lg font-semibold"
style={{ fontFamily: "'Montserrat', sans-serif" }}
>
Donate
</Button>
</Link>
<Button
onClick={() => {
setIsMobileMenuOpen(false);
handleLogout();
}}
variant="outline"
className="w-full border-2 border-white/30 text-white hover:bg-white/10 rounded-lg"
style={{ fontFamily: "'Poppins', sans-serif" }}
data-testid="mobile-logout-button"
>
Logout
</Button>
</div>
</div>
</div>
)}
</> </>
); );
}; };

View File

@@ -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 (
<Badge className={`${roleConfig.className} px-3 py-1 rounded-full text-sm`}>
{roleConfig.label}
</Badge>
);
};
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 (
<div className="text-center py-8">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Loading invitations...
</p>
</div>
);
}
if (invitations.length === 0) {
return (
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
<Mail className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
No Pending Invitations
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
All invitations have been accepted or expired
</p>
</Card>
);
}
return (
<>
<Card className="bg-white rounded-2xl border border-[#ddd8eb] overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-[#DDD8EB] hover:bg-[#DDD8EB]">
<TableHead className="text-[#422268] font-semibold">Email</TableHead>
<TableHead className="text-[#422268] font-semibold">Name</TableHead>
<TableHead className="text-[#422268] font-semibold">Role</TableHead>
<TableHead className="text-[#422268] font-semibold">Invited</TableHead>
<TableHead className="text-[#422268] font-semibold">Expires</TableHead>
<TableHead className="text-[#422268] font-semibold text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitations.map((invitation) => (
<TableRow key={invitation.id} className="hover:bg-[#F9F8FB]">
<TableCell className="font-medium text-[#422268]">
{invitation.email}
</TableCell>
<TableCell className="text-[#664fa3]">
{invitation.first_name && invitation.last_name
? `${invitation.first_name} ${invitation.last_name}`
: '-'}
</TableCell>
<TableCell>{getRoleBadge(invitation.role)}</TableCell>
<TableCell className="text-[#664fa3]">
{new Date(invitation.invited_at).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Clock className={`h-4 w-4 ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500' : 'text-[#664fa3]'}`} />
<span className={`text-sm ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500 font-semibold' : 'text-[#664fa3]'}`}>
{formatDate(invitation.expires_at)}
</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleResend(invitation.id)}
disabled={resending === invitation.id}
className="rounded-xl border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
>
{resending === invitation.id ? (
'Resending...'
) : (
<>
<MailCheck className="h-4 w-4 mr-1" />
Resend
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setRevokeDialog({ open: true, invitation })}
className="rounded-xl border-red-500 text-red-500 hover:bg-red-500 hover:text-white"
>
<Trash2 className="h-4 w-4 mr-1" />
Revoke
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Revoke Confirmation Dialog */}
<AlertDialog open={revokeDialog.open} onOpenChange={(open) => setRevokeDialog({ open, invitation: null })}>
<AlertDialogContent className="rounded-2xl">
<AlertDialogHeader>
<AlertDialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Revoke Invitation
</AlertDialogTitle>
<AlertDialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Are you sure you want to revoke the invitation for{' '}
<span className="font-semibold">{revokeDialog.invitation?.email}</span>?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-xl">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevoke}
className="rounded-xl bg-red-500 hover:bg-red-600 text-white"
>
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default PendingInvitationsTable;

View File

@@ -50,12 +50,12 @@ const PublicFooter = () => {
<footer className="bg-gradient-to-r from-[#48286e] to-[#644c9f] border-t border-[rgba(0,0,0,0.1)] px-4 sm:px-8 md:px-16 py-6 md:py-8"> <footer className="bg-gradient-to-r from-[#48286e] to-[#644c9f] border-t border-[rgba(0,0,0,0.1)] px-4 sm:px-8 md:px-16 py-6 md:py-8">
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 justify-between items-center max-w-7xl mx-auto"> <div className="flex flex-col sm:flex-row gap-4 sm:gap-6 justify-between items-center max-w-7xl mx-auto">
<nav className="flex flex-col sm:flex-row gap-4 sm:gap-8 items-center order-1 sm:order-none"> <nav className="flex flex-col sm:flex-row gap-4 sm:gap-8 items-center order-1 sm:order-none">
<a href="/#terms" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Inter', sans-serif" }}> <Link to="/terms-of-service" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Inter', sans-serif" }}>
Terms of Service Terms of Service
</a> </Link>
<a href="/#privacy" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Inter', sans-serif" }}> <Link to="/privacy-policy" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Inter', sans-serif" }}>
Privacy Policy Privacy Policy
</a> </Link>
</nav> </nav>
<p className="text-[#c5b4e3] text-sm sm:text-base font-medium text-center order-2 sm:order-none" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-[#c5b4e3] text-sm sm:text-base font-medium text-center order-2 sm:order-none" style={{ fontFamily: "'Inter', sans-serif" }}>
© 2025 LOAF. All Rights Reserved. © 2025 LOAF. All Rights Reserved.

View File

@@ -13,7 +13,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Your email is also your username that you can use to login. 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.
</p> </p>
</div> </div>
@@ -69,6 +69,43 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
</p> </p>
)} )}
</div> </div>
{/* Terms of Service Acceptance */}
<div className="p-4 bg-[#F8F7FB] rounded-lg border border-[#ddd8eb]">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="accepts_tos"
name="accepts_tos"
checked={formData.accepts_tos || false}
onChange={handleInputChange}
className="mt-1 w-4 h-4 text-[#664fa3] border-gray-300 rounded focus:ring-[#664fa3]"
required
data-testid="tos-checkbox"
/>
<label htmlFor="accepts_tos" className="text-sm text-gray-700" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
I agree to the{' '}
<a
href="/membership/terms-of-service"
target="_blank"
rel="noopener noreferrer"
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
>
Terms of Service
</a>
{' '}and{' '}
<a
href="/membership/privacy-policy"
target="_blank"
rel="noopener noreferrer"
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
>
Privacy Policy
</a>
<span className="text-red-500 ml-1">*</span>
</label>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -9,6 +9,7 @@ export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [token, setToken] = useState(localStorage.getItem('token')); const [token, setToken] = useState(localStorage.getItem('token'));
const [permissions, setPermissions] = useState([]);
useEffect(() => { useEffect(() => {
const initAuth = async () => { const initAuth = async () => {
@@ -20,9 +21,13 @@ export const AuthProvider = ({ children }) => {
}); });
setUser(response.data); setUser(response.data);
setToken(storedToken); setToken(storedToken);
// Fetch user permissions
await fetchPermissions(storedToken);
} catch (error) { } catch (error) {
localStorage.removeItem('token'); localStorage.removeItem('token');
setToken(null); setToken(null);
setPermissions([]);
} }
} }
setLoading(false); setLoading(false);
@@ -30,12 +35,34 @@ export const AuthProvider = ({ children }) => {
initAuth(); 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 login = async (email, password) => {
const response = await axios.post(`${API_URL}/api/auth/login`, { email, password }); const response = await axios.post(`${API_URL}/api/auth/login`, { email, password });
const { access_token, user: userData } = response.data; const { access_token, user: userData } = response.data;
localStorage.setItem('token', access_token); localStorage.setItem('token', access_token);
setToken(access_token); setToken(access_token);
setUser(userData); setUser(userData);
// Fetch user permissions
await fetchPermissions(access_token);
return userData; return userData;
}; };
@@ -43,6 +70,7 @@ export const AuthProvider = ({ children }) => {
localStorage.removeItem('token'); localStorage.removeItem('token');
setToken(null); setToken(null);
setUser(null); setUser(null);
setPermissions([]);
}; };
const register = async (userData) => { const register = async (userData) => {
@@ -124,10 +152,18 @@ export const AuthProvider = ({ children }) => {
return response.data; 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 ( return (
<AuthContext.Provider value={{ <AuthContext.Provider value={{
user, user,
token, token,
permissions,
login, login,
logout, logout,
register, register,
@@ -136,6 +172,7 @@ export const AuthProvider = ({ children }) => {
resetPassword, resetPassword,
changePassword, changePassword,
resendVerificationEmail, resendVerificationEmail,
hasPermission,
loading loading
}}> }}>
{children} {children}

View File

@@ -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 (
<Badge className={`${roleConfig.className} px-4 py-2 rounded-full text-sm`}>
<Shield className="h-4 w-4 mr-2 inline" />
{roleConfig.label}
</Badge>
);
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
<Card className="w-full max-w-md p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
<Loader2 className="h-12 w-12 text-[#664fa3] mx-auto mb-4 animate-spin" />
<p className="text-lg text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Verifying your invitation...
</p>
</Card>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
<Card className="w-full max-w-md p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
<XCircle className="h-16 w-16 text-red-500 mx-auto mb-6" />
<h1 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Invalid Invitation
</h1>
<p className="text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{error}
</p>
<Button
onClick={() => navigate('/login')}
className="rounded-xl bg-[#664fa3] hover:bg-[#422268] text-white"
>
Go to Login
</Button>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
<Card className="w-full max-w-3xl p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb]">
{/* Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="h-16 w-16 rounded-full bg-gradient-to-br from-[#664fa3] to-[#422268] flex items-center justify-center">
<Mail className="h-8 w-8 text-white" />
</div>
</div>
<h1 className="text-3xl md:text-4xl font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Welcome to LOAF!
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Complete your profile to accept the invitation
</p>
</div>
{/* Invitation Details */}
<div className="mb-8 p-6 bg-gradient-to-r from-[#DDD8EB] to-[#F9F8FB] rounded-xl">
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<p className="text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Email Address
</p>
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{invitation?.email}
</p>
</div>
<div>
<p className="text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Role
</p>
<div>{getRoleBadge(invitation?.role)}</div>
</div>
<div className="md:col-span-2">
<p className="text-[#664fa3] mb-1 flex items-center gap-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Calendar className="h-4 w-4" />
Invitation Expires
</p>
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{invitation?.expires_at ? new Date(invitation.expires_at).toLocaleString() : 'N/A'}
</p>
</div>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
{/* Password Fields */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="password" className="text-[#422268]">
Password <span className="text-red-500">*</span>
</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Minimum 8 characters"
/>
{formErrors.password && (
<p className="text-sm text-red-500">{formErrors.password}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword" className="text-[#422268]">
Confirm Password <span className="text-red-500">*</span>
</Label>
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Re-enter password"
/>
{formErrors.confirmPassword && (
<p className="text-sm text-red-500">{formErrors.confirmPassword}</p>
)}
</div>
</div>
{/* Name Fields */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="first_name" className="text-[#422268]">
First Name <span className="text-red-500">*</span>
</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="John"
/>
{formErrors.first_name && (
<p className="text-sm text-red-500">{formErrors.first_name}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="last_name" className="text-[#422268]">
Last Name <span className="text-red-500">*</span>
</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Doe"
/>
{formErrors.last_name && (
<p className="text-sm text-red-500">{formErrors.last_name}</p>
)}
</div>
</div>
{/* Phone */}
<div className="grid gap-2">
<Label htmlFor="phone" className="text-[#422268]">
Phone <span className="text-red-500">*</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="(555) 123-4567"
/>
{formErrors.phone && (
<p className="text-sm text-red-500">{formErrors.phone}</p>
)}
</div>
{/* Optional Fields Section */}
{invitation?.role === 'member' && (
<>
<div className="border-t border-[#ddd8eb] pt-6 mt-2">
<h3 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Additional Information (Optional)
</h3>
</div>
{/* Address */}
<div className="grid gap-2">
<Label htmlFor="address" className="text-[#422268]">Address</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="123 Main St"
/>
</div>
{/* City, State, Zipcode */}
<div className="grid md:grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="city" className="text-[#422268]">City</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleChange('city', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="San Francisco"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="state" className="text-[#422268]">State</Label>
<Input
id="state"
value={formData.state}
onChange={(e) => handleChange('state', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="CA"
maxLength={2}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="zipcode" className="text-[#422268]">Zipcode</Label>
<Input
id="zipcode"
value={formData.zipcode}
onChange={(e) => handleChange('zipcode', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="94102"
/>
</div>
</div>
{/* Date of Birth */}
<div className="grid gap-2">
<Label htmlFor="date_of_birth" className="text-[#422268]">Date of Birth</Label>
<Input
id="date_of_birth"
type="date"
value={formData.date_of_birth}
onChange={(e) => handleChange('date_of_birth', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/>
</div>
</>
)}
</div>
{/* Submit Button */}
<div className="mt-8">
<Button
type="submit"
disabled={submitting}
className="w-full h-14 rounded-xl bg-gradient-to-r from-[#81B29A] to-[#6DA085] hover:from-[#6DA085] hover:to-[#5A8F72] text-white text-lg font-semibold"
>
{submitting ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
Creating Your Account...
</>
) : (
<>
<CheckCircle className="h-5 w-5 mr-2" />
Accept Invitation & Create Account
</>
)}
</Button>
</div>
</form>
{/* Footer Note */}
<div className="mt-6 text-center">
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Already have an account?{' '}
<button
onClick={() => navigate('/login')}
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
>
Sign in instead
</button>
</p>
</div>
</Card>
</div>
);
};
export default AcceptInvitation;

View File

@@ -111,7 +111,7 @@ const BecomeMember = () => {
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]" className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }} 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.
</p> </p>
</div> </div>
</div> </div>
@@ -175,7 +175,7 @@ const BecomeMember = () => {
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]" className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }} 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.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -59,11 +59,14 @@ const Dashboard = () => {
const getStatusBadge = (status) => { const getStatusBadge = (status) => {
const statusConfig = { const statusConfig = {
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' }, 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' }, pending_validation: { icon: Clock, label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
pre_approved: { icon: CheckCircle, label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' }, 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' }, payment_pending: { icon: AlertCircle, label: 'Payment Pending', className: 'bg-orange-500 text-white' },
active: { icon: CheckCircle, label: 'Active', className: 'bg-[#81B29A] 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; const config = statusConfig[status] || statusConfig.inactive;
@@ -80,11 +83,14 @@ const Dashboard = () => {
const getStatusMessage = (status) => { const getStatusMessage = (status) => {
const messages = { const messages = {
pending_email: 'Please check your email to verify your account.', pending_email: 'Please check your email to verify your account.',
pending_approval: 'Your application is under review by our admin team.', pending_validation: 'Your application is under review by our admin team.',
pre_approved: '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.', payment_pending: 'Please complete your payment to activate your membership.',
active: 'Your membership is active! Enjoy all member benefits.', 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] || ''; return messages[status] || '';
@@ -254,14 +260,14 @@ const Dashboard = () => {
</div> </div>
{/* CTA Section */} {/* CTA Section */}
{user?.status === 'pending_approval' && ( {user?.status === 'pending_validation' && (
<Card className="mt-8 p-8 bg-gradient-to-br from-[#DDD8EB]/20 to-[#f1eef9]/20 rounded-2xl border border-[#ddd8eb]"> <Card className="mt-8 p-8 bg-gradient-to-br from-[#DDD8EB]/20 to-[#f1eef9]/20 rounded-2xl border border-[#ddd8eb]">
<div className="text-center"> <div className="text-center">
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Application Under Review Application Under Review
</h3> </h3>
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
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!
</p> </p>
</div> </div>
</Card> </Card>
@@ -278,7 +284,7 @@ const Dashboard = () => {
Complete Your Payment Complete Your Payment
</h3> </h3>
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
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.
</p> </p>
<Link to="/plans"> <Link to="/plans">
<Button <Button

View File

@@ -42,7 +42,7 @@ const Login = () => {
return; return;
} }
if (user.role === 'admin') { if (user.role === 'admin' || user.role === 'superadmin') {
navigate('/admin'); navigate('/admin');
} else { } else {
navigate('/dashboard'); navigate('/dashboard');

View File

@@ -64,7 +64,7 @@ const PaymentCancel = () => {
<div className="bg-[#f1eef9] p-6 rounded-xl"> <div className="bg-[#f1eef9] p-6 rounded-xl">
<p className="text-sm text-[#664fa3] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-[#664fa3] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<span className="font-medium text-[#422268]">Note:</span>{' '} <span className="font-medium text-[#422268]">Note:</span>{' '}
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.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -47,16 +47,16 @@ const Plans = () => {
canView: true, canView: true,
canSubscribe: false canSubscribe: false
}, },
pending_approval: { pending_validation: {
title: "Application Under Review", 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, action: null,
canView: true, canView: true,
canSubscribe: false canSubscribe: false
}, },
pre_approved: { pre_validated: {
title: "Application Under Review", 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, action: null,
canView: true, canView: true,
canSubscribe: false canSubscribe: false
@@ -77,10 +77,31 @@ const Plans = () => {
}, },
inactive: { inactive: {
title: "Membership 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, action: null,
canView: true, canView: true,
canSubscribe: 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... Processing...
</> </>
) : statusInfo && !statusInfo.canSubscribe ? ( ) : statusInfo && !statusInfo.canSubscribe ? (
'Approval Required' 'Validation Required'
) : ( ) : (
'Choose Amount & Subscribe' 'Choose Amount & Subscribe'
)} )}

249
src/pages/PrivacyPolicy.js Normal file
View File

@@ -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 (
<>
<PublicNavbar />
<div className="min-h-screen bg-[#F8F7FB]">
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-12">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
Privacy Policy
</h1>
<p className="text-base sm:text-lg text-gray-600" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
LOAFers, Inc. Website Privacy Policy
</p>
</div>
{/* Content */}
<div className="bg-white rounded-lg border border-[#ddd8eb] p-6 sm:p-8 md:p-10 space-y-8">
{/* Introduction */}
<section>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 1: What User Data We Collect */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4 flex items-center gap-2"
style={{ fontFamily: "'Inter', sans-serif" }}>
<span>💻</span> What User Data We Collect
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
When you visit the Site, we may collect the following data:
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li>Your <strong>IP address</strong></li>
<li>Your <strong>contact information and email address</strong></li>
</ul>
<p className="text-gray-700 leading-relaxed mt-4">
When you apply for membership, we collect the following data:
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li><strong>First and last name</strong></li>
<li><strong>Mailing address</strong></li>
<li><strong>Email</strong></li>
<li><strong>Phone number</strong></li>
<li><strong>Birthday</strong></li>
</ul>
<p className="text-gray-700 leading-relaxed mt-4">
If you choose to pay your membership administrative fee online, we have access to:
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li><strong>Partial credit card information</strong></li>
</ul>
<p className="text-gray-700 leading-relaxed mt-4">
You may also choose to provide the following:
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li><strong>Partner's name</strong></li>
<li><strong>Photo</strong></li>
<li><strong>Self-bio</strong></li>
<li><strong>Consent to receive our eNewsletter</strong></li>
<li><strong>Consent to display an online profile visible only to membership</strong></li>
</ul>
</div>
</section>
{/* Section 2: Why We Collect Your Data */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4 flex items-center gap-2"
style={{ fontFamily: "'Inter', sans-serif" }}>
<span>🎯</span> Why We Collect Your Data
</h2>
<div className="prose max-w-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li>To send you announcement emails containing the information about our events and information we think you will find interesting.</li>
<li>To contact you to fill out surveys about our membership.</li>
<li>To customize our blog according to your online behavior and personal preferences.</li>
</ul>
</div>
</section>
{/* Section 3: Sharing Information with Third Parties */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4 flex items-center gap-2"
style={{ fontFamily: "'Inter', sans-serif" }}>
<span>🤝</span> Sharing Information with Third Parties
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
The Company does not sell, rent, or lease personal data to third parties.
</p>
<p className="text-gray-700 leading-relaxed">
The Company may share data with trusted partners to help perform statistical analysis, provide customer support.
</p>
<p className="text-gray-700 leading-relaxed">
The Company uses <strong>Stripe</strong> to process online payments at which time users would no longer be governed by the Company's Privacy Policy.
</p>
<p className="text-gray-700 leading-relaxed">
The Company may disclose your personal information, without notice, if required to do so by law.
</p>
</div>
</section>
{/* Section 4: Safeguarding and Securing the Data */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4 flex items-center gap-2"
style={{ fontFamily: "'Inter', sans-serif" }}>
<span>🔒</span> Safeguarding and Securing the Data
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 5: Our Cookie Policy */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4 flex items-center gap-2"
style={{ fontFamily: "'Inter', sans-serif" }}>
<span>🍪</span> Our Cookie Policy
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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).
</p>
<p className="text-gray-700 leading-relaxed">
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.
</p>
<p className="text-gray-700 leading-relaxed">
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.
</p>
<p className="text-gray-700 leading-relaxed">
If you want to disable cookies, you can do it by accessing the settings of your internet browser. You can visit{' '}
<a href="https://www.internetcookies.com" target="_blank" rel="noopener noreferrer" className="text-[#664fa3] hover:text-[#422268] font-semibold">
https://www.internetcookies.com
</a>, which contains comprehensive information on how to do this on a wide variety of browsers and devices.
</p>
</div>
</section>
{/* Section 6: Links to Other Websites */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4 flex items-center gap-2"
style={{ fontFamily: "'Inter', sans-serif" }}>
<span>🔗</span> Links to Other Websites
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 7: Restricting the Collection of your Personal Data */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4 flex items-center gap-2"
style={{ fontFamily: "'Inter', sans-serif" }}>
<span>🚫</span> Restricting the Collection of your Personal Data
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
At some point, you might wish to restrict the use and collection of your personal data. You can achieve this by doing the following:
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li>Log in to your online profile and make any changes you wish to your profile information.</li>
<li>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.</li>
</ul>
</div>
</section>
{/* Section 8: Children Under Thirteen */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4 flex items-center gap-2"
style={{ fontFamily: "'Inter', sans-serif" }}>
<span>👶</span> Children Under Thirteen
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
The Company does not knowingly collect information from children under the age of 13.
</p>
</div>
</section>
{/* Section 9: Changes to this Statement */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4 flex items-center gap-2"
style={{ fontFamily: "'Inter', sans-serif" }}>
<span>🗓️</span> Changes to this Statement
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
The Company may make changes to this Policy. When this occurs the effective date of this policy will be updated.
</p>
</div>
</section>
{/* Section 10: Contact Information */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4 flex items-center gap-2"
style={{ fontFamily: "'Inter', sans-serif" }}>
<span>📧</span> Contact Information
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
If you have any questions, please contact LOAFers, Inc. at:
</p>
<div className="bg-[#F8F7FB] p-4 rounded-lg border border-[#ddd8eb]">
<p className="font-semibold text-gray-800">LOAFers, Inc.</p>
<p className="text-gray-700">PO Box 7207</p>
<p className="text-gray-700">Houston, TX 77248-7207</p>
<p className="text-gray-700 mt-2">
Email: <a href="mailto:info@loaftx.org" className="text-[#664fa3] hover:text-[#422268] font-semibold">info@loaftx.org</a>
</p>
</div>
</div>
</section>
</div>
{/* Back to Home Link */}
<div className="mt-8 text-center">
<Link to="/" className="text-[#664fa3] hover:text-[#422268] font-semibold transition-colors inline-flex items-center gap-2"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<span></span> Back to Home
</Link>
</div>
</div>
</div>
<PublicFooter />
</>
);
}

317
src/pages/TermsOfService.js Normal file
View File

@@ -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 (
<>
<PublicNavbar />
<div className="min-h-screen bg-[#F8F7FB]">
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-12">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
Terms of Service
</h1>
<p className="text-base sm:text-lg text-gray-600" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Last Updated: January 2025
</p>
</div>
{/* Content */}
<div className="bg-white rounded-lg border border-[#ddd8eb] p-6 sm:p-8 md:p-10 space-y-8">
{/* Section 1: Agreement to Terms */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
1. Agreement to Terms
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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").
</p>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 2: Intellectual Property Rights */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
2. Intellectual Property Rights
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 3: User Representations */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
3. User Representations
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
By using the Site, you represent and warrant that:
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li>All registration information you submit will be true, accurate, current, and complete</li>
<li>You will maintain the accuracy of such information and promptly update such registration information as necessary</li>
<li>You have the legal capacity and you agree to comply with these Terms of Service</li>
<li>You are not under the age of 13</li>
<li>Not a minor in the jurisdiction in which you reside, or if a minor, you have received parental permission to use the Site</li>
<li>You will not access the Site through automated or non-human means</li>
<li>You will not use the Site for any illegal or unauthorized purpose</li>
<li>Your use of the Site will not violate any applicable law or regulation</li>
</ul>
</div>
</section>
{/* Section 4: Prohibited Activities */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
4. Prohibited Activities
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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:
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li>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</li>
<li>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</li>
<li>Circumvent, disable, or otherwise interfere with security-related features of the Site</li>
<li>Engage in unauthorized framing of or linking to the Site</li>
<li>Trick, defraud, or mislead us and other users, especially in any attempt to learn sensitive account information such as user passwords</li>
<li>Make improper use of our support services or submit false reports of abuse or misconduct</li>
<li>Engage in any automated use of the system, such as using scripts to send comments or messages</li>
<li>Interfere with, disrupt, or create an undue burden on the Site or the networks or services connected to the Site</li>
</ul>
</div>
</section>
{/* Section 5: User Generated Contributions */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
5. User Generated Contributions
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 6: Contribution License */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
6. Contribution License
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 7: Submissions */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
7. Submissions
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 8: Site Management */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
8. Site Management
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 9: Term and Termination */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
9. Term and Termination
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 10: Modifications and Interruptions */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
10. Modifications and Interruptions
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 11: Governing Law */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
11. Governing Law
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 12: Dispute Resolution */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
12. Dispute Resolution
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 13: Corrections */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
13. Corrections
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 14: Disclaimer */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
14. Disclaimer
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 15: Limitations of Liability */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
15. Limitations of Liability
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 16: Indemnification */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
16. Indemnification
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 17: User Data */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
17. User Data
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 18: Electronic Communications */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
18. Electronic Communications, Transactions, and Signatures
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
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.
</p>
</div>
</section>
{/* Section 19: Contact Us */}
<section>
<h2 className="text-xl sm:text-2xl font-bold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}>
19. Contact Us
</h2>
<div className="prose max-w-none space-y-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-gray-700 leading-relaxed">
In order to resolve a complaint regarding the Site or to receive further information regarding use of the Site, please contact us at:
</p>
<div className="bg-[#F8F7FB] p-4 rounded-lg border border-[#ddd8eb]">
<p className="font-semibold text-gray-800">LOAFers, Inc.</p>
<p className="text-gray-700">PO Box 7207</p>
<p className="text-gray-700">Houston, TX 77249</p>
<p className="text-gray-700 mt-2">
Email: <a href="mailto:info@loaftx.org" className="text-[#664fa3] hover:text-[#422268] font-semibold">info@loaftx.org</a>
</p>
</div>
</div>
</section>
</div>
{/* Back to Home Link */}
<div className="mt-8 text-center">
<Link to="/" className="text-[#664fa3] hover:text-[#422268] font-semibold transition-colors inline-flex items-center gap-2"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<span></span> Back to Home
</Link>
</div>
</div>
</div>
<PublicFooter />
</>
);
}

View File

@@ -4,14 +4,15 @@ import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; 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 AdminDashboard = () => {
const [stats, setStats] = useState({ const [stats, setStats] = useState({
totalMembers: 0, totalMembers: 0,
pendingApprovals: 0, pendingValidations: 0,
activeMembers: 0 activeMembers: 0
}); });
const [usersNeedingAttention, setUsersNeedingAttention] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@@ -25,11 +26,27 @@ const AdminDashboard = () => {
setStats({ setStats({
totalMembers: users.filter(u => u.role === 'member').length, totalMembers: users.filter(u => u.role === 'member').length,
pendingApprovals: users.filter(u => pendingValidations: users.filter(u =>
['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(u.status) ['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
).length, ).length,
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').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) { } catch (error) {
console.error('Failed to fetch stats:', error); console.error('Failed to fetch stats:', error);
} finally { } finally {
@@ -62,16 +79,16 @@ const AdminDashboard = () => {
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p> <p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
</Card> </Card>
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-approvals"> <Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-validations">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="bg-orange-100 p-3 rounded-lg"> <div className="bg-orange-100 p-3 rounded-lg">
<Clock className="h-6 w-6 text-orange-600" /> <Clock className="h-6 w-6 text-orange-600" />
</div> </div>
</div> </div>
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.pendingApprovals} {loading ? '-' : stats.pendingValidations}
</p> </p>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Approvals</p> <p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
</Card> </Card>
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-active-members"> <Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-active-members">
@@ -89,42 +106,123 @@ const AdminDashboard = () => {
{/* Quick Actions */} {/* Quick Actions */}
<div className="grid md:grid-cols-2 gap-8"> <div className="grid md:grid-cols-2 gap-8">
<Link to="/admin/users"> <Link to="/admin/members">
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users"> <Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
<Users className="h-12 w-12 text-[#664fa3] mb-4" /> <Users className="h-12 w-12 text-[#664fa3] mb-4" />
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Manage Users Manage Members
</h3> </h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View and manage all registered users and their membership status. View and manage paying members and their subscription status.
</p> </p>
<Button <Button
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full" className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
data-testid="manage-users-button" data-testid="manage-users-button"
> >
Go to Users Go to Members
</Button> </Button>
</Card> </Card>
</Link> </Link>
<Link to="/admin/approvals"> <Link to="/admin/validations">
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-approvals"> <Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-validations">
<Clock className="h-12 w-12 text-orange-600 mb-4" /> <Clock className="h-12 w-12 text-orange-600 mb-4" />
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Approval Queue Validation Queue
</h3> </h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Review and approve pending membership applications. Review and validate pending membership applications.
</p> </p>
<Button <Button
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full" className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
data-testid="manage-approvals-button" data-testid="manage-validations-button"
> >
View Approvals View Validations
</Button> </Button>
</Card> </Card>
</Link> </Link>
</div> </div>
{/* Users Needing Attention Widget */}
{usersNeedingAttention.length > 0 && (
<div className="mt-12">
<Card className="p-8 bg-white rounded-2xl border-2 border-[#ff9e77] shadow-lg">
<div className="flex items-center gap-3 mb-6">
<div className="bg-[#ff9e77]/20 p-3 rounded-lg">
<AlertCircle className="h-6 w-6 text-[#ff9e77]" />
</div>
<div>
<h3 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Needing Personal Outreach
</h3>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
These members have received multiple reminder emails. Consider calling them directly.
</p>
</div>
</div>
<div className="space-y-4">
{usersNeedingAttention.map(user => (
<Link key={user.id} to={`/admin/users/${user.id}`}>
<div className="p-4 bg-[#F8F7FB] rounded-xl border border-[#ddd8eb] hover:border-[#ff9e77] hover:shadow-md transition-all cursor-pointer">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</h4>
<Badge className="bg-[#ff9e77] text-white px-3 py-1 rounded-full text-xs">
{user.totalReminders} reminder{user.totalReminders !== 1 ? 's' : ''}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p>
<p>Phone: {user.phone || 'N/A'}</p>
<p className="capitalize">Status: {user.status.replace('_', ' ')}</p>
{user.email_verification_reminders_sent > 0 && (
<p>
<Mail className="inline h-3 w-3 mr-1" />
{user.email_verification_reminders_sent} email verification reminder{user.email_verification_reminders_sent !== 1 ? 's' : ''}
</p>
)}
{user.event_attendance_reminders_sent > 0 && (
<p>
<Calendar className="inline h-3 w-3 mr-1" />
{user.event_attendance_reminders_sent} event reminder{user.event_attendance_reminders_sent !== 1 ? 's' : ''}
</p>
)}
{user.payment_reminders_sent > 0 && (
<p>
<Clock className="inline h-3 w-3 mr-1" />
{user.payment_reminders_sent} payment reminder{user.payment_reminders_sent !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
<Button
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full text-sm"
onClick={(e) => {
e.preventDefault();
window.location.href = `tel:${user.phone}`;
}}
>
Call Member
</Button>
</div>
</div>
</Link>
))}
</div>
<div className="mt-6 p-4 bg-[#DDD8EB]/20 rounded-lg border border-[#ddd8eb]">
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<strong>💡 Tip for helping older members:</strong> 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.
</p>
</div>
</Card>
</div>
)}
</> </>
); );
}; };

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; 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'; import { AttendanceDialog } from '../../components/AttendanceDialog';
const AdminEvents = () => { const AdminEvents = () => {
const { hasPermission } = useAuth();
const [events, setEvents] = useState([]); const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
@@ -141,6 +143,7 @@ const AdminEvents = () => {
</p> </p>
</div> </div>
{(hasPermission('events.create') || hasPermission('events.edit')) && (
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}> <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
@@ -273,6 +276,7 @@ const AdminEvents = () => {
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)}
</div> </div>
{/* Events List */} {/* Events List */}

View File

@@ -1,18 +1,30 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; 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 { 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 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 AdminMembers = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { hasPermission } = useAuth();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -20,6 +32,13 @@ const AdminMembers = () => {
const [statusFilter, setStatusFilter] = useState('active'); const [statusFilter, setStatusFilter] = useState('active');
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null); 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(() => { useEffect(() => {
fetchMembers(); fetchMembers();
@@ -70,14 +89,127 @@ const AdminMembers = () => {
fetchMembers(); // Refresh list 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 getStatusBadge = (status) => {
const config = { const config = {
pending_email: { label: 'Pending Email', className: 'bg-orange-100 text-orange-700' }, pending_email: { label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
pending_approval: { label: 'Pending Approval', className: 'bg-gray-200 text-gray-700' }, pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' }, pre_validated: { label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' }, payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' },
active: { label: 'Active', className: 'bg-[#81B29A] 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; 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 ( return (
<> <>
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <div className="flex justify-between items-start mb-4">
Members Management <div>
</h1> <h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> Members Management
Manage paying members and their subscriptions. </h1>
</p> <p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage paying members and their subscriptions.
</p>
</div>
<div className="flex gap-3 flex-wrap">
{hasPermission('users.export') && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
disabled={exporting}
>
{exporting ? (
<>
<Download className="h-5 w-5 mr-2 animate-bounce" />
Exporting...
</>
) : (
<>
<FileDown className="h-5 w-5 mr-2" />
Export
<ChevronDown className="h-4 w-4 ml-2" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="rounded-xl">
<DropdownMenuItem onClick={() => handleExport('all')} className="cursor-pointer">
Export All Members
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('current')} className="cursor-pointer">
Export Current View
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{hasPermission('users.import') && (
<Button
onClick={() => setImportDialogOpen(true)}
className="bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-xl h-12 px-6"
>
<Upload className="h-5 w-5 mr-2" />
Import
</Button>
)}
{hasPermission('users.invite') && (
<Button
onClick={() => setInviteDialogOpen(true)}
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
>
<Mail className="h-5 w-5 mr-2" />
Invite Member
</Button>
)}
{hasPermission('users.create') && (
<Button
onClick={() => setCreateDialogOpen(true)}
className="bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-xl h-12 px-6"
>
<UserPlus className="h-5 w-5 mr-2" />
Create Member
</Button>
)}
</div>
</div>
</div> </div>
{/* Stats */} {/* Stats */}
@@ -148,9 +367,12 @@ const AdminMembers = () => {
<SelectItem value="all">All Statuses</SelectItem> <SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="active">Active</SelectItem> <SelectItem value="active">Active</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem> <SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="pending_approval">Pending Approval</SelectItem> <SelectItem value="pending_validation">Pending Validation</SelectItem>
<SelectItem value="pre_approved">Pre-Approved</SelectItem> <SelectItem value="pre_validated">Pre-Validated</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem> <SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="canceled">Canceled</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
<SelectItem value="abandoned">Abandoned</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -192,45 +414,112 @@ const AdminMembers = () => {
<p>Referred by: {user.referred_by_member_name}</p> <p>Referred by: {user.referred_by_member_name}</p>
)} )}
</div> </div>
{/* Reminder Info */}
{(() => {
const reminderInfo = getReminderInfo(user);
if (reminderInfo.totalReminders > 0) {
return (
<div className="mt-4 p-3 bg-[#F8F7FB] rounded-lg border border-[#ddd8eb]">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-[#ff9e77]" />
<span className="text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{reminderInfo.totalReminders} reminder{reminderInfo.totalReminders !== 1 ? 's' : ''} sent
{reminderInfo.totalReminders >= 3 && (
<Badge className="ml-2 bg-[#ff9e77] text-white px-2 py-0.5 rounded-full text-xs">
Needs attention
</Badge>
)}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{reminderInfo.emailReminders > 0 && (
<p>
<Mail className="inline h-3 w-3 mr-1" />
{reminderInfo.emailReminders} email verification
</p>
)}
{reminderInfo.eventReminders > 0 && (
<p>
<Calendar className="inline h-3 w-3 mr-1" />
{reminderInfo.eventReminders} event attendance
</p>
)}
{reminderInfo.paymentReminders > 0 && (
<p>
<Clock className="inline h-3 w-3 mr-1" />
{reminderInfo.paymentReminders} payment
</p>
)}
{reminderInfo.renewalReminders > 0 && (
<p>
<CheckCircle className="inline h-3 w-3 mr-1" />
{reminderInfo.renewalReminders} renewal
</p>
)}
</div>
{reminderInfo.lastReminderAt && (
<p className="mt-2 text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Last reminder: {new Date(reminderInfo.lastReminderAt).toLocaleDateString()} at {new Date(reminderInfo.lastReminderAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
);
}
return null;
})()}
</div> </div>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex gap-2"> <div className="flex flex-col gap-3">
<Link to={`/admin/users/${user.id}`}> <div className="flex gap-2 flex-wrap">
<Button <Link to={`/admin/users/${user.id}`}>
variant="outline" <Button
size="sm" variant="outline"
className="border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white" size="sm"
> className="border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white"
<Eye className="h-4 w-4 mr-1" /> >
View Profile <Eye className="h-4 w-4 mr-1" />
</Button> View Profile
</Link> </Button>
</Link>
{/* Show Activate Payment button for payment_pending users */} {/* Show Activate Payment button for payment_pending users */}
{user.status === 'payment_pending' && ( {user.status === 'payment_pending' && (
<Button <Button
onClick={() => handleActivatePayment(user)} onClick={() => handleActivatePayment(user)}
size="sm" size="sm"
className="bg-[#DDD8EB] text-[#422268] hover:bg-white" className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
> >
<CheckCircle className="h-4 w-4 mr-1" /> <CheckCircle className="h-4 w-4 mr-1" />
Activate Payment Activate Payment
</Button> </Button>
)} )}
</div>
{/* Show Subscription button for active users */} {/* Status Management */}
{user.status === 'active' && ( <div className="flex items-center gap-2">
<Button <span className="text-sm text-[#664fa3] whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
variant="outline" Change Status:
size="sm" </span>
className="border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white" <Select
value={user.status}
onValueChange={(newStatus) => handleStatusChangeRequest(user.id, user.status, newStatus, user)}
disabled={statusChanging === user.id}
> >
<CreditCard className="h-4 w-4 mr-1" /> <SelectTrigger className="w-[180px] h-9 border-[#ddd8eb]">
Subscription <SelectValue />
</Button> </SelectTrigger>
)} <SelectContent>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="canceled">Canceled</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
</div> </div>
</Card> </Card>
@@ -257,6 +546,34 @@ const AdminMembers = () => {
user={selectedUserForPayment} user={selectedUserForPayment}
onSuccess={handlePaymentSuccess} onSuccess={handlePaymentSuccess}
/> />
{/* Status Change Confirmation Dialog */}
<ConfirmationDialog
open={confirmDialogOpen}
onOpenChange={setConfirmDialogOpen}
onConfirm={confirmStatusChange}
loading={statusChanging !== null}
{...getStatusChangeMessage()}
/>
{/* Create/Invite/Import Dialogs */}
<CreateMemberDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={fetchMembers}
/>
<InviteStaffDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
onSuccess={fetchMembers}
/>
<ImportMembersDialog
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
onSuccess={fetchMembers}
/>
</> </>
); );
}; };

View File

@@ -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 (
<div className={`inline-flex items-center gap-2 ${roleConfig.color} text-white px-4 py-2 rounded-full`}>
<Icon className="h-4 w-4" />
<span className="font-semibold">{roleConfig.label}</span>
</div>
);
};
if (loading) {
return (
<div className="text-center py-20">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Loading permissions...
</p>
</div>
);
}
if (!hasPermission('permissions.assign')) {
return (
<div className="text-center py-20">
<Lock className="h-20 w-20 text-red-500 mx-auto mb-6" />
<h2 className="text-3xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Access Denied
</h2>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You don't have permission to manage role permissions.
</p>
<p className="text-sm text-gray-500 mt-2">
Only Superadmins can access this page.
</p>
</div>
);
}
return (
<>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Permission Management
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Configure granular permissions for each role. Superadmin always has all permissions.
</p>
</div>
{/* Role Tabs */}
<Tabs value={selectedRole} onValueChange={setSelectedRole} className="mb-8">
<TabsList className="grid w-full grid-cols-3 mb-8">
<TabsTrigger value="admin" className="text-lg py-3">
{getRoleBadge('admin')}
</TabsTrigger>
<TabsTrigger value="member" className="text-lg py-3">
{getRoleBadge('member')}
</TabsTrigger>
<TabsTrigger value="guest" className="text-lg py-3">
{getRoleBadge('guest')}
</TabsTrigger>
</TabsList>
{['admin', 'member', 'guest'].map(role => (
<TabsContent key={role} value={role}>
{/* Stats */}
<div className="grid md:grid-cols-3 gap-4 mb-8">
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Permissions
</p>
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{permissions.length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Assigned
</p>
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedPermissions[role].length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Modules
</p>
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{Object.keys(groupedPermissions).length}
</p>
</Card>
</div>
{/* Permissions by Module */}
<div className="space-y-4">
{Object.entries(groupedPermissions).map(([module, perms]) => (
<Card key={module} className="bg-white rounded-2xl border border-[#ddd8eb] overflow-hidden">
{/* Module Header */}
<div
className="p-6 bg-gradient-to-r from-[#DDD8EB] to-white cursor-pointer hover:from-[#C5BFD9] transition-colors"
onClick={() => toggleModuleExpansion(module)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Checkbox
checked={isModuleFullySelected(role, module)}
onCheckedChange={() => toggleModule(role, module)}
onClick={(e) => e.stopPropagation()}
className="h-6 w-6 border-2 border-[#664fa3] data-[state=checked]:bg-[#664fa3]"
/>
<div>
<h3 className="text-xl font-semibold text-[#422268] capitalize" style={{ fontFamily: "'Inter', sans-serif" }}>
{module}
</h3>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{getModuleProgress(role, module)} permissions
</p>
</div>
</div>
{expandedModules[module] ? (
<ChevronUp className="h-6 w-6 text-[#664fa3]" />
) : (
<ChevronDown className="h-6 w-6 text-[#664fa3]" />
)}
</div>
</div>
{/* Module Permissions */}
{expandedModules[module] && (
<div className="p-6 pt-0">
<div className="grid md:grid-cols-2 gap-4 mt-4">
{perms.map(perm => (
<div
key={perm.code}
className="flex items-start gap-4 p-4 rounded-xl hover:bg-[#F9F8FB] transition-colors border border-transparent hover:border-[#ddd8eb]"
>
<Checkbox
checked={selectedPermissions[role].includes(perm.code)}
onCheckedChange={() => togglePermission(role, perm.code)}
className="mt-1 h-5 w-5 border-2 border-[#664fa3] data-[state=checked]:bg-[#664fa3]"
/>
<div className="flex-1">
<p className="font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{perm.name}
</p>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{perm.description}
</p>
<p className="text-xs text-gray-400 mt-1 font-mono">
{perm.code}
</p>
</div>
</div>
))}
</div>
</div>
)}
</Card>
))}
</div>
</TabsContent>
))}
</Tabs>
{/* Superadmin Note */}
<Card className="p-6 bg-gradient-to-r from-[#664fa3] to-[#422268] rounded-2xl border-none mb-8">
<div className="flex items-start gap-4">
<Lock className="h-6 w-6 text-white flex-shrink-0 mt-1" />
<div className="text-white">
<p className="font-semibold mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Superadmin Permissions
</p>
<p className="text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Superadmins automatically have all permissions and cannot be restricted. This ensures you can never lock yourself out of the system.
</p>
</div>
</div>
</Card>
{/* Save Button */}
{hasChanges && (
<div className="fixed bottom-8 right-8 z-50">
<Button
onClick={handleSave}
disabled={saving}
className="h-14 px-8 bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-full shadow-lg text-lg font-semibold"
>
<Save className="h-5 w-5 mr-2" />
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
)}
{/* Confirmation Dialog */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent className="rounded-2xl">
<AlertDialogHeader>
<AlertDialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Confirm Permission Changes
</AlertDialogTitle>
<AlertDialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Are you sure you want to update permissions for <span className="font-semibold capitalize">{selectedRole}</span>?
This will immediately affect all users with this role.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-xl">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmSave}
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default AdminPermissions;

View File

@@ -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 (
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Loading roles...</div>
</div>
);
}
const groupedPermissions = groupPermissionsByModule();
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Role Management</h1>
<p className="text-gray-600 mt-1">
Create and manage custom roles with specific permissions
</p>
</div>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Role
</Button>
</div>
{/* Roles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{roles.map(role => (
<Card key={role.id} className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<Shield className="w-5 h-5 text-blue-600 mr-2" />
<div>
<h3 className="font-semibold">{role.name}</h3>
<p className="text-sm text-gray-500">{role.code}</p>
</div>
</div>
{role.is_system_role && (
<Lock className="w-4 h-4 text-gray-400" title="System Role" />
)}
</div>
<p className="text-sm text-gray-600 mb-4 min-h-[40px]">
{role.description || 'No description'}
</p>
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-gray-500">
{role.permission_count} permissions
</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedRole(role);
fetchRolePermissions(role.id);
setShowPermissionsModal(true);
}}
>
Manage Permissions
</Button>
{!role.is_system_role && (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedRole(role);
setFormData({
name: role.name,
description: role.description || ''
});
setShowEditModal(true);
}}
>
<Edit className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
setSelectedRole(role);
setShowDeleteDialog(true);
}}
>
<Trash2 className="w-3 h-3" />
</Button>
</>
)}
</div>
</Card>
))}
</div>
{/* Create Role Modal */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Role</DialogTitle>
<DialogDescription>
Create a custom role with specific permissions for your team members.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Role Code *</Label>
<Input
placeholder="e.g., content_editor, finance_manager"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
/>
<p className="text-xs text-gray-500 mt-1">
Lowercase, no spaces. Used internally to identify the role.
</p>
</div>
<div>
<Label>Role Name *</Label>
<Input
placeholder="e.g., Content Editor, Finance Manager"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<Label>Description</Label>
<Textarea
placeholder="Describe what this role can do..."
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div>
<Label>Permissions</Label>
<p className="text-sm text-gray-600 mb-3">
Select permissions for this role. You can also add permissions later.
</p>
<div className="border rounded-lg p-4 max-h-64 overflow-y-auto">
{Object.entries(groupedPermissions).map(([module, perms]) => (
<div key={module} className="mb-4">
<button
onClick={() => toggleModule(module)}
className="flex items-center w-full text-left font-medium mb-2 hover:text-blue-600"
>
{expandedModules[module] ? (
<ChevronUp className="w-4 h-4 mr-1" />
) : (
<ChevronDown className="w-4 h-4 mr-1" />
)}
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
</button>
{expandedModules[module] && (
<div className="space-y-2 ml-5">
{perms.map(perm => (
<div key={perm.code} className="flex items-center">
<Checkbox
checked={formData.permissions.includes(perm.code)}
onCheckedChange={() => {
setFormData(prev => ({
...prev,
permissions: prev.permissions.includes(perm.code)
? prev.permissions.filter(p => p !== perm.code)
: [...prev.permissions, perm.code]
}));
}}
/>
<label className="ml-2 text-sm">
<span className="font-medium">{perm.name}</span>
<span className="text-gray-500 ml-2">({perm.code})</span>
</label>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateModal(false)}>
Cancel
</Button>
<Button onClick={handleCreateRole}>
Create Role
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Role Modal */}
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Role</DialogTitle>
<DialogDescription>
Update role name and description. Code cannot be changed.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Role Code</Label>
<Input value={selectedRole?.code || ''} disabled />
</div>
<div>
<Label>Role Name *</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowEditModal(false)}>
Cancel
</Button>
<Button onClick={handleUpdateRole}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Manage Permissions Modal */}
<Dialog open={showPermissionsModal} onOpenChange={setShowPermissionsModal}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Manage Permissions: {selectedRole?.name}</DialogTitle>
<DialogDescription>
Select which permissions this role should have.
</DialogDescription>
</DialogHeader>
<div className="border rounded-lg p-4">
{Object.entries(groupedPermissions).map(([module, perms]) => (
<div key={module} className="mb-6">
<button
onClick={() => toggleModule(module)}
className="flex items-center w-full text-left font-medium text-lg mb-3 hover:text-blue-600"
>
{expandedModules[module] ? (
<ChevronUp className="w-5 h-5 mr-2" />
) : (
<ChevronDown className="w-5 h-5 mr-2" />
)}
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
</button>
{expandedModules[module] && (
<div className="space-y-3 ml-7">
{perms.map(perm => (
<div key={perm.code} className="flex items-start">
<Checkbox
checked={selectedPermissions.includes(perm.code)}
onCheckedChange={() => togglePermission(perm.code)}
/>
<div className="ml-3">
<label className="font-medium text-sm">
{perm.name}
</label>
<p className="text-xs text-gray-500">{perm.description}</p>
<span className="text-xs text-gray-400">{perm.code}</span>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPermissionsModal(false)}>
Cancel
</Button>
<Button onClick={handleSavePermissions}>
Save Permissions
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Role?</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteRole} className="bg-red-600">
Delete Role
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default AdminRoles;

View File

@@ -1,22 +1,31 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; 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 { toast } from 'sonner';
import { UserCog, Search, Shield } from 'lucide-react'; import { UserCog, Search, Shield, UserPlus, Mail } from 'lucide-react';
const AdminStaff = () => { const AdminStaff = () => {
const { hasPermission } = useAuth();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [roleFilter, setRoleFilter] = useState('all'); 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) // Staff roles (non-guest, non-member) - includes all admin-type roles
const STAFF_ROLES = ['admin']; const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
useEffect(() => { useEffect(() => {
fetchStaff(); fetchStaff();
@@ -95,12 +104,36 @@ const AdminStaff = () => {
return ( return (
<> <>
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <div className="flex justify-between items-start mb-4">
Staff Management <div>
</h1> <h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> Staff Management
Manage internal team members and their roles. </h1>
</p> <p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage internal team members and their roles.
</p>
</div>
<div className="flex gap-3">
{hasPermission('users.invite') && (
<Button
onClick={() => setInviteDialogOpen(true)}
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
>
<Mail className="h-5 w-5 mr-2" />
Invite Staff
</Button>
)}
{hasPermission('users.create') && (
<Button
onClick={() => setCreateDialogOpen(true)}
className="bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-xl h-12 px-6"
>
<UserPlus className="h-5 w-5 mr-2" />
Create Staff
</Button>
)}
</div>
</div>
</div> </div>
{/* Stats */} {/* Stats */}
@@ -131,91 +164,127 @@ const AdminStaff = () => {
</Card> </Card>
</div> </div>
{/* Filters */} {/* Tabs */}
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] mb-8"> <Tabs value={activeTab} onValueChange={setActiveTab} className="mb-8">
<div className="grid md:grid-cols-2 gap-4"> <TabsList className="grid w-full grid-cols-2 mb-8">
<div className="relative"> <TabsTrigger value="staff-list" className="text-lg py-3">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" /> <UserCog className="h-5 w-5 mr-2" />
<Input Staff Members
placeholder="Search by name or email..." </TabsTrigger>
value={searchQuery} <TabsTrigger value="pending-invitations" className="text-lg py-3">
onChange={(e) => setSearchQuery(e.target.value)} <Mail className="h-5 w-5 mr-2" />
className="pl-12 h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" Pending Invitations
data-testid="search-staff-input" </TabsTrigger>
/> </TabsList>
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#ddd8eb]" data-testid="role-filter-select">
<SelectValue placeholder="Filter by role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
<SelectItem value="superadmin">Superadmin</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="staff">Staff</SelectItem>
<SelectItem value="media">Media</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Staff List */} <TabsContent value="staff-list">
{loading ? ( {/* Filters */}
<div className="text-center py-20"> <Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading staff...</p> <div className="grid md:grid-cols-2 gap-4">
</div> <div className="relative">
) : filteredUsers.length > 0 ? ( <Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
<div className="space-y-4"> <Input
{filteredUsers.map((user) => ( placeholder="Search by name or email..."
<Card value={searchQuery}
key={user.id} onChange={(e) => setSearchQuery(e.target.value)}
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-md transition-shadow" className="pl-12 h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
data-testid={`staff-card-${user.id}`} data-testid="search-staff-input"
> />
<div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex items-start gap-4 flex-1">
{/* Avatar */}
<div className="h-14 w-14 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold text-lg flex-shrink-0">
{user.first_name?.[0]}{user.last_name?.[0]}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<h3 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</h3>
{getRoleBadge(user.role)}
{getStatusBadge(user.status)}
</div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
{user.last_login && (
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p>
)}
</div>
</div>
</div>
</div> </div>
</Card> <Select value={roleFilter} onValueChange={setRoleFilter}>
))} <SelectTrigger className="h-14 rounded-xl border-2 border-[#ddd8eb]" data-testid="role-filter-select">
</div> <SelectValue placeholder="Filter by role" />
) : ( </SelectTrigger>
<div className="text-center py-20"> <SelectContent>
<UserCog className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" /> <SelectItem value="all">All Roles</SelectItem>
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <SelectItem value="superadmin">Superadmin</SelectItem>
No Staff Found <SelectItem value="admin">Admin</SelectItem>
</h3> <SelectItem value="moderator">Moderator</SelectItem>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <SelectItem value="staff">Staff</SelectItem>
{searchQuery || roleFilter !== 'all' <SelectItem value="media">Media</SelectItem>
? 'Try adjusting your filters' </SelectContent>
: 'No staff members yet'} </Select>
</p> </div>
</div> </Card>
)}
{/* Staff List */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading staff...</p>
</div>
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-md transition-shadow"
data-testid={`staff-card-${user.id}`}
>
<div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex items-start gap-4 flex-1">
{/* Avatar */}
<div className="h-14 w-14 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold text-lg flex-shrink-0">
{user.first_name?.[0]}{user.last_name?.[0]}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<h3 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</h3>
{getRoleBadge(user.role)}
{getStatusBadge(user.status)}
</div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
{user.last_login && (
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p>
)}
</div>
</div>
</div>
</div>
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<UserCog className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Staff Found
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{searchQuery || roleFilter !== 'all'
? 'Try adjusting your filters'
: 'No staff members yet'}
</p>
</div>
)}
</TabsContent>
<TabsContent value="pending-invitations">
<PendingInvitationsTable />
</TabsContent>
</Tabs>
{/* Dialogs */}
<CreateStaffDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={fetchStaff}
/>
<InviteStaffDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
onSuccess={() => {
// Optionally refresh invitations table
setActiveTab('pending-invitations');
}}
/>
</> </>
); );
}; };

View File

@@ -6,6 +6,7 @@ import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle } from 'lucide-react'; import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import ConfirmationDialog from '../../components/ConfirmationDialog';
const AdminUserView = () => { const AdminUserView = () => {
const { userId } = useParams(); const { userId } = useParams();
@@ -14,9 +15,14 @@ const AdminUserView = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [resetPasswordLoading, setResetPasswordLoading] = useState(false); const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [resendVerificationLoading, setResendVerificationLoading] = 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(() => { useEffect(() => {
fetchUserProfile(); fetchUserProfile();
fetchSubscriptions();
}, [userId]); }, [userId]);
const fetchUserProfile = async () => { const fetchUserProfile = async () => {
@@ -31,52 +37,91 @@ const AdminUserView = () => {
} }
}; };
const handleResetPassword = async () => { const fetchSubscriptions = 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);
try { try {
await api.put(`/admin/users/${userId}/reset-password`, { const response = await api.get(`/admin/subscriptions?user_id=${userId}`);
force_change: true setSubscriptions(response.data);
});
toast.success(`Password reset email sent to ${user.email}`);
} catch (error) { } catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to reset password'; console.error('Failed to fetch subscriptions:', error);
toast.error(errorMessage);
} finally { } finally {
setResetPasswordLoading(false); setSubscriptionsLoading(false);
} }
}; };
const handleResendVerification = async () => { const handleResetPasswordRequest = () => {
const confirmed = window.confirm( setPendingAction({ type: 'reset_password' });
`Resend verification email to ${user.email}?` setConfirmDialogOpen(true);
); };
if (!confirmed) return; const handleResendVerificationRequest = () => {
setPendingAction({ type: 'resend_verification' });
setConfirmDialogOpen(true);
};
setResendVerificationLoading(true); const confirmAction = async () => {
if (!pendingAction) return;
try { const { type } = pendingAction;
await api.post(`/admin/users/${userId}/resend-verification`); setConfirmDialogOpen(false);
toast.success(`Verification email sent to ${user.email}`);
// Refresh user data to get updated email_verified status if changed if (type === 'reset_password') {
await fetchUserProfile(); setResetPasswordLoading(true);
} catch (error) { try {
const errorMessage = error.response?.data?.detail || 'Failed to send verification email'; await api.put(`/admin/users/${userId}/reset-password`, {
toast.error(errorMessage); force_change: true
} finally { });
setResendVerificationLoading(false); 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 <div>Loading...</div>; if (loading) return <div>Loading...</div>;
if (!user) return null; if (!user) return null;
@@ -141,7 +186,7 @@ const AdminUserView = () => {
</h2> </h2>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button <Button
onClick={handleResetPassword} onClick={handleResetPasswordRequest}
disabled={resetPasswordLoading} disabled={resetPasswordLoading}
variant="outline" variant="outline"
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-4 py-2 disabled:opacity-50" className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-4 py-2 disabled:opacity-50"
@@ -152,7 +197,7 @@ const AdminUserView = () => {
{!user.email_verified && ( {!user.email_verified && (
<Button <Button
onClick={handleResendVerification} onClick={handleResendVerificationRequest}
disabled={resendVerificationLoading} disabled={resendVerificationLoading}
variant="outline" variant="outline"
className="border-2 border-[#ff9e77] text-[#ff9e77] hover:bg-[#FFF3E0] rounded-full px-4 py-2 disabled:opacity-50" className="border-2 border-[#ff9e77] text-[#ff9e77] hover:bg-[#FFF3E0] rounded-full px-4 py-2 disabled:opacity-50"
@@ -223,10 +268,100 @@ const AdminUserView = () => {
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}> <h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Information Subscription Information
</h2> </h2>
{/* TODO: Fetch and display subscription data */}
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Subscription details coming soon...</p> {subscriptionsLoading ? (
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading subscriptions...</p>
) : subscriptions.length === 0 ? (
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No subscriptions found for this member.</p>
) : (
<div className="space-y-6">
{subscriptions.map((sub) => (
<div key={sub.id} className="p-6 bg-[#F8F7FB] rounded-xl border border-[#ddd8eb]">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{sub.plan.name}
</h3>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.plan.billing_cycle}
</p>
</div>
<Badge className={
sub.status === 'active' ? 'bg-[#81B29A] text-white' :
sub.status === 'expired' ? 'bg-red-500 text-white' :
'bg-gray-400 text-white'
}>
{sub.status}
</Badge>
</div>
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Start Date</label>
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(sub.start_date).toLocaleDateString()}
</p>
</div>
{sub.end_date && (
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>End Date</label>
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(sub.end_date).toLocaleDateString()}
</p>
</div>
)}
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Base Amount</label>
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.base_subscription_cents / 100).toFixed(2)}
</p>
</div>
{sub.donation_cents > 0 && (
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Donation</label>
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.donation_cents / 100).toFixed(2)}
</p>
</div>
)}
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Paid</label>
<p className="text-[#422268] font-semibold" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.amount_paid_cents / 100).toFixed(2)}
</p>
</div>
{sub.payment_method && (
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Method</label>
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.payment_method}
</p>
</div>
)}
{sub.stripe_subscription_id && (
<div className="md:col-span-2">
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Stripe Subscription ID</label>
<p className="text-[#422268] text-xs font-mono" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.stripe_subscription_id}
</p>
</div>
)}
</div>
</div>
))}
</div>
)}
</Card> </Card>
)} )}
{/* Admin Action Confirmation Dialog */}
<ConfirmationDialog
open={confirmDialogOpen}
onOpenChange={setConfirmDialogOpen}
onConfirm={confirmAction}
loading={resetPasswordLoading || resendVerificationLoading}
{...getActionMessage()}
/>
</> </>
); );
}; };

View File

@@ -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 (
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
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 (
<>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
User Management
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View and manage all registered users.
</p>
</div>
{/* Filters */}
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
<div className="grid md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
data-testid="search-users-input"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#ddd8eb]" data-testid="status-filter-select">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending_email">Pending Email</SelectItem>
<SelectItem value="pending_approval">Pending Approval</SelectItem>
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Users List */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading users...</p>
</div>
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-md transition-shadow"
data-testid={`user-card-${user.id}`}
>
<div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</h3>
{getStatusBadge(user.status)}
</div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Role: <span className="capitalize">{user.role}</span></p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
{user.referred_by_member_name && (
<p className="col-span-2">Referred by: {user.referred_by_member_name}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => navigate(`/admin/users/${user.id}`)}
variant="ghost"
size="sm"
className="text-[#664fa3] hover:text-[#422268]"
>
<Eye className="h-4 w-4 mr-1" />
View
</Button>
{!user.email_verified && (
<Button
onClick={() => handleAdminResendVerification(user.id, user.email)}
disabled={resendingUserId === user.id}
variant="ghost"
size="sm"
className="text-[#ff9e77] hover:text-[#664fa3]"
>
<Mail className="h-4 w-4 mr-1" />
{resendingUserId === user.id ? 'Sending...' : 'Resend Verification'}
</Button>
)}
</div>
</div>
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<Users className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Users Found
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{searchQuery || statusFilter !== 'all'
? 'Try adjusting your filters'
: 'No users registered yet'}
</p>
</div>
)}
</>
);
};
export default AdminUsers;

View File

@@ -31,14 +31,17 @@ import {
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown } from 'lucide-react'; import { CheckCircle, Clock, Search, ArrowUp, ArrowDown } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog'; import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog';
const AdminApprovals = () => { const AdminValidations = () => {
const [pendingUsers, setPendingUsers] = useState([]); const [pendingUsers, setPendingUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(null); const [actionLoading, setActionLoading] = useState(null);
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null); const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [pendingAction, setPendingAction] = useState(null);
// Filtering state // Filtering state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -68,7 +71,7 @@ const AdminApprovals = () => {
try { try {
const response = await api.get('/admin/users'); const response = await api.get('/admin/users');
const pending = response.data.filter(user => 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); setPendingUsers(pending);
} catch (error) { } catch (error) {
@@ -120,37 +123,65 @@ const AdminApprovals = () => {
setFilteredUsers(filtered); setFilteredUsers(filtered);
}; };
const handleApprove = async (userId) => { const handleValidateRequest = (user) => {
setActionLoading(userId); 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 { try {
await api.put(`/admin/users/${userId}/approve`); if (type === 'validate') {
toast.success('User validated and approved! Payment email sent.'); 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(); fetchPendingUsers();
} catch (error) { } catch (error) {
toast.error('Failed to approve user'); toast.error(error.response?.data?.detail || 'Failed to validate user');
} finally { } finally {
setActionLoading(null); setActionLoading(null);
setPendingAction(null);
} }
}; };
const handleBypassAndApprove = async (userId) => { const getActionMessage = () => {
if (!window.confirm( if (!pendingAction) return {};
'This will bypass email verification and approve the user. ' +
'Are you sure you want to proceed?' const { type, user } = pendingAction;
)) { const userName = `${user.first_name} ${user.last_name}`;
return;
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); if (type === 'bypass_and_validate') {
try { return {
await api.put(`/admin/users/${userId}/approve?bypass_email_verification=true`); title: 'Bypass Email & Validate User?',
toast.success('User email verified and approved! Payment email sent.'); 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.`,
fetchPendingUsers(); variant: 'warning',
} catch (error) { confirmText: 'Yes, Bypass & Validate',
toast.error(error.response?.data?.detail || 'Failed to approve user'); };
} finally {
setActionLoading(null);
} }
return {};
}; };
const handleActivatePayment = (user) => { const handleActivatePayment = (user) => {
@@ -165,8 +196,8 @@ const AdminApprovals = () => {
const getStatusBadge = (status) => { const getStatusBadge = (status) => {
const config = { const config = {
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' }, pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' },
pending_approval: { label: 'Pending', className: 'bg-gray-200 text-gray-700' }, pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' }, pre_validated: { label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' } payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' }
}; };
@@ -206,10 +237,10 @@ const AdminApprovals = () => {
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Approval Queue Validation Queue
</h1> </h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Review and approve pending membership applications. Review and validate pending membership applications.
</p> </p>
</div> </div>
@@ -229,15 +260,15 @@ const AdminApprovals = () => {
</p> </p>
</div> </div>
<div> <div>
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Approval</p> <p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validation</p>
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{pendingUsers.filter(u => u.status === 'pending_approval').length} {pendingUsers.filter(u => u.status === 'pending_validation').length}
</p> </p>
</div> </div>
<div> <div>
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Approved</p> <p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Validated</p>
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{pendingUsers.filter(u => u.status === 'pre_approved').length} {pendingUsers.filter(u => u.status === 'pre_validated').length}
</p> </p>
</div> </div>
<div> <div>
@@ -269,8 +300,8 @@ const AdminApprovals = () => {
<SelectContent> <SelectContent>
<SelectItem value="all">All Statuses</SelectItem> <SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending_email">Awaiting Email</SelectItem> <SelectItem value="pending_email">Awaiting Email</SelectItem>
<SelectItem value="pending_approval">Pending Approval</SelectItem> <SelectItem value="pending_validation">Pending Validation</SelectItem>
<SelectItem value="pre_approved">Pre-Approved</SelectItem> <SelectItem value="pre_validated">Pre-Validated</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -330,12 +361,12 @@ const AdminApprovals = () => {
<div className="flex gap-2"> <div className="flex gap-2">
{user.status === 'pending_email' ? ( {user.status === 'pending_email' ? (
<Button <Button
onClick={() => handleBypassAndApprove(user.id)} onClick={() => handleBypassAndValidateRequest(user)}
disabled={actionLoading === user.id} disabled={actionLoading === user.id}
size="sm" size="sm"
className="bg-[#DDD8EB] text-[#422268] hover:bg-white" className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
> >
{actionLoading === user.id ? 'Approving...' : 'Bypass & Approve'} {actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
</Button> </Button>
) : user.status === 'payment_pending' ? ( ) : user.status === 'payment_pending' ? (
<Button <Button
@@ -348,12 +379,12 @@ const AdminApprovals = () => {
</Button> </Button>
) : ( ) : (
<Button <Button
onClick={() => handleApprove(user.id)} onClick={() => handleValidateRequest(user)}
disabled={actionLoading === user.id} disabled={actionLoading === user.id}
size="sm" size="sm"
className="bg-[#81B29A] text-white hover:bg-[#6FA087]" className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
> >
{actionLoading === user.id ? 'Validating...' : 'Approve'} {actionLoading === user.id ? 'Validating...' : 'Validate'}
</Button> </Button>
)} )}
</div> </div>
@@ -445,7 +476,7 @@ const AdminApprovals = () => {
<div className="text-center py-20"> <div className="text-center py-20">
<Clock className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" /> <Clock className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Pending Approvals No Pending Validations
</h3> </h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{searchQuery || statusFilter !== 'all' {searchQuery || statusFilter !== 'all'
@@ -462,8 +493,17 @@ const AdminApprovals = () => {
user={selectedUserForPayment} user={selectedUserForPayment}
onSuccess={handlePaymentSuccess} onSuccess={handlePaymentSuccess}
/> />
{/* Validation Confirmation Dialog */}
<ConfirmationDialog
open={confirmDialogOpen}
onOpenChange={setConfirmDialogOpen}
onConfirm={confirmAction}
loading={actionLoading !== null}
{...getActionMessage()}
/>
</> </>
); );
}; };
export default AdminApprovals; export default AdminValidations;

View File

@@ -13,7 +13,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '../../components/ui/dialog'; } 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'; import { useToast } from '../../hooks/use-toast';
const MembersDirectory = () => { const MembersDirectory = () => {
@@ -139,6 +139,17 @@ const MembersDirectory = () => {
</p> </p>
)} )}
{/* Member Since */}
<div className="flex items-center justify-center gap-2 mb-4">
<Calendar className="h-4 w-4 text-[#664fa3]" />
<span className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Member since {new Date(member.member_since || member.created_at).toLocaleDateString('en-US', {
month: 'long',
year: 'numeric'
})}
</span>
</div>
{/* Contact Information */} {/* Contact Information */}
<div className="space-y-3 mb-4"> <div className="space-y-3 mb-4">
{member.directory_email && ( {member.directory_email && (