Compare commits
3 Commits
main
...
ac850d65d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac850d65d3 | ||
|
|
40a8930b93 | ||
|
|
4d80f9aca5 |
31
src/App.js
31
src/App.js
@@ -17,13 +17,12 @@ import BecomeMember from './pages/BecomeMember';
|
||||
import PaymentSuccess from './pages/PaymentSuccess';
|
||||
import PaymentCancel from './pages/PaymentCancel';
|
||||
import AdminDashboard from './pages/admin/AdminDashboard';
|
||||
import AdminUsers from './pages/admin/AdminUsers';
|
||||
import AdminUserView from './pages/admin/AdminUserView';
|
||||
import AdminStaff from './pages/admin/AdminStaff';
|
||||
import AdminMembers from './pages/admin/AdminMembers';
|
||||
import AdminPermissions from './pages/admin/AdminPermissions';
|
||||
import AdminRoles from './pages/admin/AdminRoles';
|
||||
import AdminEvents from './pages/admin/AdminEvents';
|
||||
import AdminValidations from './pages/admin/AdminValidations';
|
||||
import AdminApprovals from './pages/admin/AdminApprovals';
|
||||
import AdminPlans from './pages/admin/AdminPlans';
|
||||
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
@@ -47,8 +46,6 @@ import Donate from './pages/Donate';
|
||||
import DonationSuccess from './pages/DonationSuccess';
|
||||
import Resources from './pages/Resources';
|
||||
import ContactUs from './pages/ContactUs';
|
||||
import TermsOfService from './pages/TermsOfService';
|
||||
import PrivacyPolicy from './pages/PrivacyPolicy';
|
||||
|
||||
const PrivateRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, loading } = useAuth();
|
||||
@@ -61,7 +58,7 @@ const PrivateRoute = ({ children, adminOnly = false }) => {
|
||||
return <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
if (adminOnly && !['admin', 'superadmin'].includes(user.role)) {
|
||||
if (adminOnly && user.role !== 'admin') {
|
||||
return <Navigate to="/dashboard" />;
|
||||
}
|
||||
|
||||
@@ -108,10 +105,6 @@ function App() {
|
||||
<Route path="/donate" element={<Donate />} />
|
||||
<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={
|
||||
<PrivateRoute>
|
||||
<Dashboard />
|
||||
@@ -196,6 +189,13 @@ function App() {
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/users" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminUsers />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/users/:userId" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
@@ -210,10 +210,10 @@ function App() {
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/validations" element={
|
||||
<Route path="/admin/approvals" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminValidations />
|
||||
<AdminApprovals />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
@@ -259,13 +259,6 @@ function App() {
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/permissions" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminRoles />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
</Routes>
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -39,7 +39,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
try {
|
||||
const response = await api.get('/admin/users');
|
||||
const pending = response.data.filter(u =>
|
||||
['pending_validation', 'pre_validated'].includes(u.status)
|
||||
['pending_approval', 'pre_approved'].includes(u.status)
|
||||
);
|
||||
setPendingCount(pending.length);
|
||||
} catch (error) {
|
||||
@@ -105,9 +105,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'Validations',
|
||||
name: 'Approvals',
|
||||
icon: CheckCircle,
|
||||
path: '/admin/validations',
|
||||
path: '/admin/approvals',
|
||||
disabled: false,
|
||||
badge: pendingCount
|
||||
},
|
||||
@@ -154,9 +154,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'Permissions',
|
||||
name: 'Roles',
|
||||
icon: Shield,
|
||||
path: '/admin/permissions',
|
||||
path: '/admin/roles',
|
||||
disabled: false,
|
||||
superadminOnly: true
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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;
|
||||
@@ -1,336 +0,0 @@
|
||||
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;
|
||||
@@ -1,254 +0,0 @@
|
||||
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;
|
||||
@@ -1,335 +0,0 @@
|
||||
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;
|
||||
@@ -1,313 +0,0 @@
|
||||
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;
|
||||
@@ -32,7 +32,7 @@ const Navbar = () => {
|
||||
Welcome, {user.first_name}
|
||||
</span>
|
||||
)}
|
||||
{(user?.role === 'admin' || user?.role === 'superadmin') && (
|
||||
{user?.role === 'admin' && (
|
||||
<Link to="/admin">
|
||||
<button
|
||||
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
|
||||
@@ -320,7 +320,7 @@ const Navbar = () => {
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="p-4 border-t border-white/20 space-y-3">
|
||||
{(user?.role === 'admin' || user?.role === 'superadmin') && (
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
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;
|
||||
@@ -8,12 +8,12 @@ const PublicFooter = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Main Footer */}
|
||||
<footer className="bg-[#644c9f] px-4 sm:px-8 md:px-16 py-12 md:py-20 flex items-center justify-center min-h-[420px]">
|
||||
<div className="border-t border-[rgba(0,0,0,0.1)] py-8 md:py-12 lg:py-20 flex flex-col lg:flex-row gap-8 sm:gap-12 md:gap-16 lg:gap-20 xl:gap-30 items-center justify-center w-full max-w-7xl">
|
||||
<footer className="bg-[#644c9f] px-f sm:px-8 border-t border-[rgba(0,0,0,0.1)] py-12 md:py-20 flex items-center justify-center min-h-[420px]">
|
||||
<div className=" flex flex-col lg:flex-row gap-8 sm:gap-12 md:gap-16 lg:gap-20 xl:gap-30 items-center justify-center w-full max-w-7xl">
|
||||
<div className="w-32 sm:w-40 md:w-48 lg:w-[232px] flex-shrink-0">
|
||||
<img src={loafLogo} alt="LOAF Logo" className="w-full h-auto aspect-square object-contain" />
|
||||
</div>
|
||||
<nav className="flex flex-col sm:flex-row gap-8 sm:gap-12 md:gap-16 lg:gap-20 xl:gap-28 items-start justify-center w-full lg:w-auto">
|
||||
<nav className="flex sm:px-4 px-2 flex-col sm:flex-row gap-8 sm:gap-12 md:gap-16 lg:gap-20 xl:gap-28 items-start justify-center w-full lg:w-auto">
|
||||
<div className="flex flex-col gap-2 w-full sm:w-auto sm:min-w-[163px]">
|
||||
<div className="pb-4">
|
||||
<p className="text-white text-base font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>About</p>
|
||||
@@ -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">
|
||||
<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">
|
||||
<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" }}>
|
||||
<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" }}>
|
||||
Terms of Service
|
||||
</Link>
|
||||
<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" }}>
|
||||
</a>
|
||||
<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" }}>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</a>
|
||||
</nav>
|
||||
<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.
|
||||
|
||||
@@ -13,7 +13,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your email is also your username that you can use to login.
|
||||
Please note you can only login after your application is validated.
|
||||
Please note you can only login after your application is approved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -69,43 +69,6 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,6 @@ export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
@@ -21,13 +20,9 @@ export const AuthProvider = ({ children }) => {
|
||||
});
|
||||
setUser(response.data);
|
||||
setToken(storedToken);
|
||||
|
||||
// Fetch user permissions
|
||||
await fetchPermissions(storedToken);
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setPermissions([]);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -35,34 +30,12 @@ export const AuthProvider = ({ children }) => {
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const fetchPermissions = async (authToken) => {
|
||||
try {
|
||||
const tokenToUse = authToken || token || localStorage.getItem('token');
|
||||
if (!tokenToUse) {
|
||||
setPermissions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.get(`${API_URL}/api/auth/permissions`, {
|
||||
headers: { Authorization: `Bearer ${tokenToUse}` }
|
||||
});
|
||||
setPermissions(response.data.permissions || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch permissions:', error);
|
||||
setPermissions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
const response = await axios.post(`${API_URL}/api/auth/login`, { email, password });
|
||||
const { access_token, user: userData } = response.data;
|
||||
localStorage.setItem('token', access_token);
|
||||
setToken(access_token);
|
||||
setUser(userData);
|
||||
|
||||
// Fetch user permissions
|
||||
await fetchPermissions(access_token);
|
||||
|
||||
return userData;
|
||||
};
|
||||
|
||||
@@ -70,7 +43,6 @@ export const AuthProvider = ({ children }) => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setPermissions([]);
|
||||
};
|
||||
|
||||
const register = async (userData) => {
|
||||
@@ -152,18 +124,10 @@ export const AuthProvider = ({ children }) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const hasPermission = (permissionCode) => {
|
||||
if (!user) return false;
|
||||
// Superadmin always has all permissions
|
||||
if (user.role === 'superadmin') return true;
|
||||
return permissions.includes(permissionCode);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
token,
|
||||
permissions,
|
||||
login,
|
||||
logout,
|
||||
register,
|
||||
@@ -172,7 +136,6 @@ export const AuthProvider = ({ children }) => {
|
||||
resetPassword,
|
||||
changePassword,
|
||||
resendVerificationEmail,
|
||||
hasPermission,
|
||||
loading
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
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;
|
||||
@@ -111,7 +111,7 @@ const BecomeMember = () => {
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Complete the online application form and submit it. Check your email for a confirmation link and use it to verify your email. You will then begin to receive LOAF's monthly e-newsletter where all of the social events are listed. Your application will remain pending, and you won't be able to log into the Members Only section of the website until step 2 is complete and you are validated 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 approved by an admin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,7 +175,7 @@ const BecomeMember = () => {
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Once we know that you are indeed you, an admin will validate 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 approve your application and you will receive an email prompting you to login to your user profile and pay the annual administrative fee.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,14 +59,11 @@ const Dashboard = () => {
|
||||
const getStatusBadge = (status) => {
|
||||
const statusConfig = {
|
||||
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
|
||||
pending_validation: { icon: Clock, label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_validated: { icon: CheckCircle, label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
|
||||
pending_approval: { icon: Clock, label: 'Pending Approval', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_approved: { icon: CheckCircle, label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
|
||||
payment_pending: { icon: AlertCircle, label: 'Payment Pending', className: 'bg-orange-500 text-white' },
|
||||
active: { icon: CheckCircle, label: 'Active', className: 'bg-[#81B29A] text-white' },
|
||||
inactive: { icon: AlertCircle, label: 'Inactive', className: 'bg-gray-400 text-white' },
|
||||
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' }
|
||||
inactive: { icon: AlertCircle, label: 'Inactive', className: 'bg-gray-400 text-white' }
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig.inactive;
|
||||
@@ -83,14 +80,11 @@ const Dashboard = () => {
|
||||
const getStatusMessage = (status) => {
|
||||
const messages = {
|
||||
pending_email: 'Please check your email to verify your account.',
|
||||
pending_validation: 'Your application is under review by our admin team.',
|
||||
pre_validated: 'Your application is under review by our admin team.',
|
||||
pending_approval: 'Your application is under review by our admin team.',
|
||||
pre_approved: 'Your application is under review by our admin team.',
|
||||
payment_pending: 'Please complete your payment to activate your membership.',
|
||||
active: 'Your membership is active! Enjoy all member benefits.',
|
||||
inactive: 'Your membership is currently inactive.',
|
||||
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.'
|
||||
inactive: 'Your membership is currently inactive.'
|
||||
};
|
||||
|
||||
return messages[status] || '';
|
||||
@@ -260,14 +254,14 @@ const Dashboard = () => {
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
{user?.status === 'pending_validation' && (
|
||||
{user?.status === 'pending_approval' && (
|
||||
<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">
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Application Under Review
|
||||
</h3>
|
||||
<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 validated!
|
||||
Your membership application is being reviewed by our admin team. You'll be notified once approved!
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -284,7 +278,7 @@ const Dashboard = () => {
|
||||
Complete Your Payment
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Great news! Your membership application has been validated. Complete your payment to activate your membership and gain full access to all member benefits.
|
||||
Great news! Your membership application has been approved. Complete your payment to activate your membership and gain full access to all member benefits.
|
||||
</p>
|
||||
<Link to="/plans">
|
||||
<Button
|
||||
|
||||
@@ -54,7 +54,7 @@ const Landing = () => {
|
||||
</section>
|
||||
|
||||
{/* Feature Cards Section */}
|
||||
<section className="bg-gradient-to-b from-[#f1eef9] to-[#ddd8eb] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-12 sm:py-16 md:py-20 lg:py-30 flex flex-col md:flex-row gap-6 sm:gap-8 items-start justify-center">
|
||||
<section className="bg-gradient-to-b from-[#f1eef9] to-[#ddd8eb] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-12 sm:py-16 md:py-20 lg:py-30 flex flex-col md:flex-row gap-6 sm:gap-8 items-stretch justify-center">
|
||||
<Card className="bg-white rounded-2xl overflow-hidden flex flex-col gap-3.5 items-center pt-5 pb-0 w-full max-w-[363px]">
|
||||
<img src={iconMeetGreet} alt="Meet and Greet" className="w-full max-w-[300px] h-auto aspect-[10/9] object-contain" />
|
||||
<div className="p-6 flex flex-col gap-4.5 w-full">
|
||||
|
||||
@@ -42,7 +42,7 @@ const Login = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.role === 'admin' || user.role === 'superadmin') {
|
||||
if (user.role === 'admin') {
|
||||
navigate('/admin');
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
|
||||
@@ -64,7 +64,7 @@ const PaymentCancel = () => {
|
||||
<div className="bg-[#f1eef9] p-6 rounded-xl">
|
||||
<p className="text-sm text-[#664fa3] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<span className="font-medium text-[#422268]">Note:</span>{' '}
|
||||
Your membership application is still validated. You can complete payment whenever you're ready.
|
||||
Your membership application is still approved. You can complete payment whenever you're ready.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,16 +47,16 @@ const Plans = () => {
|
||||
canView: true,
|
||||
canSubscribe: false
|
||||
},
|
||||
pending_validation: {
|
||||
pending_approval: {
|
||||
title: "Application Under Review",
|
||||
message: "Your application is being reviewed by our admin team. You'll receive an email once validated to proceed with payment.",
|
||||
message: "Your application is being reviewed by our admin team. You'll receive an email once approved to proceed with payment.",
|
||||
action: null,
|
||||
canView: true,
|
||||
canSubscribe: false
|
||||
},
|
||||
pre_validated: {
|
||||
pre_approved: {
|
||||
title: "Application Under Review",
|
||||
message: "Your application is being reviewed by our admin team. You'll receive an email once validated to proceed with payment.",
|
||||
message: "Your application is being reviewed by our admin team. You'll receive an email once approved to proceed with payment.",
|
||||
action: null,
|
||||
canView: true,
|
||||
canSubscribe: false
|
||||
@@ -77,31 +77,10 @@ const Plans = () => {
|
||||
},
|
||||
inactive: {
|
||||
title: "Membership Inactive",
|
||||
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.",
|
||||
message: "Your membership has expired. Please select a plan below to renew your membership.",
|
||||
action: null,
|
||||
canView: true,
|
||||
canSubscribe: true
|
||||
},
|
||||
expired: {
|
||||
title: "Membership Expired",
|
||||
message: "Your membership has expired. Please renew by selecting a plan below.",
|
||||
action: null,
|
||||
canView: true,
|
||||
canSubscribe: true
|
||||
},
|
||||
abandoned: {
|
||||
title: "Application Incomplete",
|
||||
message: "Your application was not completed. Please contact support to restart the registration process.",
|
||||
action: null,
|
||||
canView: true,
|
||||
canSubscribe: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -336,7 +315,7 @@ const Plans = () => {
|
||||
Processing...
|
||||
</>
|
||||
) : statusInfo && !statusInfo.canSubscribe ? (
|
||||
'Validation Required'
|
||||
'Approval Required'
|
||||
) : (
|
||||
'Choose Amount & Subscribe'
|
||||
)}
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,17 +31,14 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
|
||||
const AdminValidations = () => {
|
||||
const AdminApprovals = () => {
|
||||
const [pendingUsers, setPendingUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(null);
|
||||
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
|
||||
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState(null);
|
||||
|
||||
// Filtering state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -71,7 +68,7 @@ const AdminValidations = () => {
|
||||
try {
|
||||
const response = await api.get('/admin/users');
|
||||
const pending = response.data.filter(user =>
|
||||
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(user.status)
|
||||
['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(user.status)
|
||||
);
|
||||
setPendingUsers(pending);
|
||||
} catch (error) {
|
||||
@@ -123,65 +120,37 @@ const AdminValidations = () => {
|
||||
setFilteredUsers(filtered);
|
||||
};
|
||||
|
||||
const handleValidateRequest = (user) => {
|
||||
setPendingAction({ type: 'validate', user });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleBypassAndValidateRequest = (user) => {
|
||||
setPendingAction({ type: 'bypass_and_validate', user });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
const { type, user } = pendingAction;
|
||||
setActionLoading(user.id);
|
||||
setConfirmDialogOpen(false);
|
||||
|
||||
const handleApprove = async (userId) => {
|
||||
setActionLoading(userId);
|
||||
try {
|
||||
if (type === 'validate') {
|
||||
await api.put(`/admin/users/${user.id}/validate`);
|
||||
toast.success('User validated! Payment email sent.');
|
||||
} else if (type === 'bypass_and_validate') {
|
||||
await api.put(`/admin/users/${user.id}/validate?bypass_email_verification=true`);
|
||||
toast.success('User email verified and validated! Payment email sent.');
|
||||
}
|
||||
await api.put(`/admin/users/${userId}/approve`);
|
||||
toast.success('User validated and approved! Payment email sent.');
|
||||
fetchPendingUsers();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to validate user');
|
||||
toast.error('Failed to approve user');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getActionMessage = () => {
|
||||
if (!pendingAction) return {};
|
||||
|
||||
const { type, user } = pendingAction;
|
||||
const userName = `${user.first_name} ${user.last_name}`;
|
||||
|
||||
if (type === 'validate') {
|
||||
return {
|
||||
title: 'Validate User?',
|
||||
description: `This will validate ${userName} and send them a payment link email. They will be able to complete payment and become an active member.`,
|
||||
variant: 'success',
|
||||
confirmText: 'Yes, Validate User',
|
||||
};
|
||||
const handleBypassAndApprove = async (userId) => {
|
||||
if (!window.confirm(
|
||||
'This will bypass email verification and approve the user. ' +
|
||||
'Are you sure you want to proceed?'
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'bypass_and_validate') {
|
||||
return {
|
||||
title: 'Bypass Email & Validate User?',
|
||||
description: `This will bypass email verification for ${userName} and validate them immediately. A payment link email will be sent. Use this only if you've confirmed their email through other means.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Bypass & Validate',
|
||||
};
|
||||
setActionLoading(userId);
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/approve?bypass_email_verification=true`);
|
||||
toast.success('User email verified and approved! Payment email sent.');
|
||||
fetchPendingUsers();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to approve user');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const handleActivatePayment = (user) => {
|
||||
@@ -196,8 +165,8 @@ const AdminValidations = () => {
|
||||
const getStatusBadge = (status) => {
|
||||
const config = {
|
||||
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' },
|
||||
pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_validated: { label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
|
||||
pending_approval: { label: 'Pending', 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' }
|
||||
};
|
||||
|
||||
@@ -237,10 +206,10 @@ const AdminValidations = () => {
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Validation Queue
|
||||
Approval Queue
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review and validate pending membership applications.
|
||||
Review and approve pending membership applications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -260,15 +229,15 @@ const AdminValidations = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validation</p>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Approval</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'pending_validation').length}
|
||||
{pendingUsers.filter(u => u.status === 'pending_approval').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Validated</p>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Approved</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'pre_validated').length}
|
||||
{pendingUsers.filter(u => u.status === 'pre_approved').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -300,8 +269,8 @@ const AdminValidations = () => {
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="pending_email">Awaiting Email</SelectItem>
|
||||
<SelectItem value="pending_validation">Pending Validation</SelectItem>
|
||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||
<SelectItem value="pending_approval">Pending Approval</SelectItem>
|
||||
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -361,12 +330,12 @@ const AdminValidations = () => {
|
||||
<div className="flex gap-2">
|
||||
{user.status === 'pending_email' ? (
|
||||
<Button
|
||||
onClick={() => handleBypassAndValidateRequest(user)}
|
||||
onClick={() => handleBypassAndApprove(user.id)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
>
|
||||
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
|
||||
{actionLoading === user.id ? 'Approving...' : 'Bypass & Approve'}
|
||||
</Button>
|
||||
) : user.status === 'payment_pending' ? (
|
||||
<Button
|
||||
@@ -379,12 +348,12 @@ const AdminValidations = () => {
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleValidateRequest(user)}
|
||||
onClick={() => handleApprove(user.id)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
|
||||
>
|
||||
{actionLoading === user.id ? 'Validating...' : 'Validate'}
|
||||
{actionLoading === user.id ? 'Validating...' : 'Approve'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -476,7 +445,7 @@ const AdminValidations = () => {
|
||||
<div className="text-center py-20">
|
||||
<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" }}>
|
||||
No Pending Validations
|
||||
No Pending Approvals
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
@@ -493,17 +462,8 @@ const AdminValidations = () => {
|
||||
user={selectedUserForPayment}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
/>
|
||||
|
||||
{/* Validation Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={confirmDialogOpen}
|
||||
onOpenChange={setConfirmDialogOpen}
|
||||
onConfirm={confirmAction}
|
||||
loading={actionLoading !== null}
|
||||
{...getActionMessage()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminValidations;
|
||||
export default AdminApprovals;
|
||||
@@ -4,15 +4,14 @@ import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle } from 'lucide-react';
|
||||
import { Users, Calendar, Clock, CheckCircle } from 'lucide-react';
|
||||
|
||||
const AdminDashboard = () => {
|
||||
const [stats, setStats] = useState({
|
||||
totalMembers: 0,
|
||||
pendingValidations: 0,
|
||||
pendingApprovals: 0,
|
||||
activeMembers: 0
|
||||
});
|
||||
const [usersNeedingAttention, setUsersNeedingAttention] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -26,27 +25,11 @@ const AdminDashboard = () => {
|
||||
|
||||
setStats({
|
||||
totalMembers: users.filter(u => u.role === 'member').length,
|
||||
pendingValidations: users.filter(u =>
|
||||
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
|
||||
pendingApprovals: users.filter(u =>
|
||||
['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(u.status)
|
||||
).length,
|
||||
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length
|
||||
});
|
||||
|
||||
// Find users who have received 3+ reminders (may need personal outreach)
|
||||
const needingAttention = users.filter(u => {
|
||||
const emailReminders = u.email_verification_reminders_sent || 0;
|
||||
const eventReminders = u.event_attendance_reminders_sent || 0;
|
||||
const paymentReminders = u.payment_reminders_sent || 0;
|
||||
const totalReminders = emailReminders + eventReminders + paymentReminders;
|
||||
return totalReminders >= 3;
|
||||
}).map(u => ({
|
||||
...u,
|
||||
totalReminders: (u.email_verification_reminders_sent || 0) +
|
||||
(u.event_attendance_reminders_sent || 0) +
|
||||
(u.payment_reminders_sent || 0)
|
||||
})).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5
|
||||
|
||||
setUsersNeedingAttention(needingAttention);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
} finally {
|
||||
@@ -79,16 +62,16 @@ const AdminDashboard = () => {
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-validations">
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-approvals">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="bg-orange-100 p-3 rounded-lg">
|
||||
<Clock className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{loading ? '-' : stats.pendingValidations}
|
||||
{loading ? '-' : stats.pendingApprovals}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Approvals</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-active-members">
|
||||
@@ -106,123 +89,42 @@ const AdminDashboard = () => {
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<Link to="/admin/members">
|
||||
<Link to="/admin/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" />
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Manage Members
|
||||
Manage Users
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View and manage paying members and their subscription status.
|
||||
View and manage all registered users and their membership status.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
data-testid="manage-users-button"
|
||||
>
|
||||
Go to Members
|
||||
Go to Users
|
||||
</Button>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<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-validations">
|
||||
<Link to="/admin/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-approvals">
|
||||
<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" }}>
|
||||
Validation Queue
|
||||
Approval Queue
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review and validate pending membership applications.
|
||||
Review and approve pending membership applications.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
data-testid="manage-validations-button"
|
||||
data-testid="manage-approvals-button"
|
||||
>
|
||||
View Validations
|
||||
View Approvals
|
||||
</Button>
|
||||
</Card>
|
||||
</Link>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -11,7 +10,6 @@ import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide
|
||||
import { AttendanceDialog } from '../../components/AttendanceDialog';
|
||||
|
||||
const AdminEvents = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
@@ -143,7 +141,6 @@ const AdminEvents = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(hasPermission('events.create') || hasPermission('events.edit')) && (
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
@@ -276,7 +273,6 @@ const AdminEvents = () => {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../components/ui/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown } from 'lucide-react';
|
||||
import { Users, Search, User, CreditCard, Eye, CheckCircle } from 'lucide-react';
|
||||
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import CreateMemberDialog from '../../components/CreateMemberDialog';
|
||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||
import ImportMembersDialog from '../../components/ImportMembersDialog';
|
||||
|
||||
const AdminMembers = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { hasPermission } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -32,13 +20,6 @@ const AdminMembers = () => {
|
||||
const [statusFilter, setStatusFilter] = useState('active');
|
||||
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
|
||||
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
|
||||
const [statusChanging, setStatusChanging] = useState(null);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingStatusChange, setPendingStatusChange] = useState(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
@@ -89,127 +70,14 @@ const AdminMembers = () => {
|
||||
fetchMembers(); // Refresh list
|
||||
};
|
||||
|
||||
const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => {
|
||||
// Skip confirmation if status didn't actually change
|
||||
if (currentStatus === newStatus) return;
|
||||
|
||||
setPendingStatusChange({ userId, newStatus, user });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmStatusChange = async () => {
|
||||
if (!pendingStatusChange) return;
|
||||
|
||||
const { userId, newStatus } = pendingStatusChange;
|
||||
setStatusChanging(userId);
|
||||
setConfirmDialogOpen(false);
|
||||
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
|
||||
toast.success('Member status updated successfully');
|
||||
fetchMembers(); // Refresh list
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update status');
|
||||
} finally {
|
||||
setStatusChanging(null);
|
||||
setPendingStatusChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (filterType) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
let params = {};
|
||||
if (filterType === 'current') {
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
params.status = statusFilter;
|
||||
}
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
}
|
||||
}
|
||||
// filterType === 'all' will export all members without filters
|
||||
|
||||
const response = await api.get('/admin/users/export', {
|
||||
params,
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `members_export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
toast.success('Members exported successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to export members');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChangeMessage = () => {
|
||||
if (!pendingStatusChange) return {};
|
||||
|
||||
const { newStatus, user } = pendingStatusChange;
|
||||
const userName = `${user.first_name} ${user.last_name}`;
|
||||
|
||||
const messages = {
|
||||
payment_pending: {
|
||||
title: 'Revert to Payment Pending?',
|
||||
description: `This will change ${userName}'s status back to Payment Pending. They will need to complete payment again to become active.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Revert Status',
|
||||
},
|
||||
active: {
|
||||
title: 'Activate Member?',
|
||||
description: `This will activate ${userName}'s membership. They will gain full access to member features and resources.`,
|
||||
variant: 'success',
|
||||
confirmText: 'Yes, Activate',
|
||||
},
|
||||
inactive: {
|
||||
title: 'Deactivate Member?',
|
||||
description: `This will deactivate ${userName}'s membership. They will lose access to member-only features but their data will be preserved.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Deactivate',
|
||||
},
|
||||
canceled: {
|
||||
title: 'Cancel Membership?',
|
||||
description: `This will mark ${userName}'s membership as canceled. This indicates they voluntarily ended their membership. Their subscription will not auto-renew.`,
|
||||
variant: 'danger',
|
||||
confirmText: 'Yes, Cancel Membership',
|
||||
},
|
||||
expired: {
|
||||
title: 'Mark Membership as Expired?',
|
||||
description: `This will mark ${userName}'s membership as expired. This indicates their subscription period has ended without renewal.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Mark as Expired',
|
||||
},
|
||||
};
|
||||
|
||||
return messages[newStatus] || {
|
||||
title: 'Confirm Status Change',
|
||||
description: `Are you sure you want to change ${userName}'s status to ${newStatus}?`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Confirm',
|
||||
};
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const config = {
|
||||
pending_email: { label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
|
||||
pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_validated: { label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
|
||||
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' },
|
||||
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' }
|
||||
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' }
|
||||
};
|
||||
|
||||
const statusConfig = config[status] || config.inactive;
|
||||
@@ -220,31 +88,9 @@ 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 (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Members Management
|
||||
</h1>
|
||||
@@ -252,71 +98,6 @@ const AdminMembers = () => {
|
||||
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>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
@@ -367,12 +148,9 @@ const AdminMembers = () => {
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||
<SelectItem value="pending_validation">Pending Validation</SelectItem>
|
||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||
<SelectItem value="pending_approval">Pending Approval</SelectItem>
|
||||
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="canceled">Canceled</SelectItem>
|
||||
<SelectItem value="expired">Expired</SelectItem>
|
||||
<SelectItem value="abandoned">Abandoned</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -414,66 +192,11 @@ const AdminMembers = () => {
|
||||
<p>Referred by: {user.referred_by_member_name}</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2">
|
||||
<Link to={`/admin/users/${user.id}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -496,30 +219,18 @@ const AdminMembers = () => {
|
||||
Activate Payment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Management */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[#664fa3] whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Change Status:
|
||||
</span>
|
||||
<Select
|
||||
value={user.status}
|
||||
onValueChange={(newStatus) => handleStatusChangeRequest(user.id, user.status, newStatus, user)}
|
||||
disabled={statusChanging === user.id}
|
||||
{/* Show Subscription button for active users */}
|
||||
{user.status === 'active' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-9 border-[#ddd8eb]">
|
||||
<SelectValue />
|
||||
</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>
|
||||
<CreditCard className="h-4 w-4 mr-1" />
|
||||
Subscription
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -546,34 +257,6 @@ const AdminMembers = () => {
|
||||
user={selectedUserForPayment}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
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;
|
||||
@@ -1,498 +0,0 @@
|
||||
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;
|
||||
@@ -1,31 +1,22 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
|
||||
import CreateStaffDialog from '../../components/CreateStaffDialog';
|
||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||
import PendingInvitationsTable from '../../components/PendingInvitationsTable';
|
||||
import { toast } from 'sonner';
|
||||
import { UserCog, Search, Shield, UserPlus, Mail } from 'lucide-react';
|
||||
import { UserCog, Search, Shield } from 'lucide-react';
|
||||
|
||||
const AdminStaff = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('all');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('staff-list');
|
||||
|
||||
// Staff roles (non-guest, non-member) - includes all admin-type roles
|
||||
const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
|
||||
// Staff roles (non-guest, non-member)
|
||||
const STAFF_ROLES = ['admin'];
|
||||
|
||||
useEffect(() => {
|
||||
fetchStaff();
|
||||
@@ -104,8 +95,6 @@ const AdminStaff = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Staff Management
|
||||
</h1>
|
||||
@@ -113,28 +102,6 @@ const AdminStaff = () => {
|
||||
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>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-4 gap-4 mb-8">
|
||||
@@ -164,20 +131,6 @@ const AdminStaff = () => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-8">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-8">
|
||||
<TabsTrigger value="staff-list" className="text-lg py-3">
|
||||
<UserCog className="h-5 w-5 mr-2" />
|
||||
Staff Members
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pending-invitations" className="text-lg py-3">
|
||||
<Mail className="h-5 w-5 mr-2" />
|
||||
Pending Invitations
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="staff-list">
|
||||
{/* Filters */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
@@ -263,28 +216,6 @@ const AdminStaff = () => {
|
||||
</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');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
|
||||
const AdminUserView = () => {
|
||||
const { userId } = useParams();
|
||||
@@ -15,14 +14,9 @@ const AdminUserView = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||
const [resendVerificationLoading, setResendVerificationLoading] = useState(false);
|
||||
const [subscriptions, setSubscriptions] = useState([]);
|
||||
const [subscriptionsLoading, setSubscriptionsLoading] = useState(true);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserProfile();
|
||||
fetchSubscriptions();
|
||||
}, [userId]);
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
@@ -37,35 +31,17 @@ const AdminUserView = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSubscriptions = async () => {
|
||||
try {
|
||||
const response = await api.get(`/admin/subscriptions?user_id=${userId}`);
|
||||
setSubscriptions(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subscriptions:', error);
|
||||
} finally {
|
||||
setSubscriptionsLoading(false);
|
||||
}
|
||||
};
|
||||
const handleResetPassword = async () => {
|
||||
const confirmed = window.confirm(
|
||||
`Reset password for ${user.first_name} ${user.last_name}?\n\n` +
|
||||
`A temporary password will be emailed to ${user.email}.\n` +
|
||||
`They will be required to change it on next login.`
|
||||
);
|
||||
|
||||
const handleResetPasswordRequest = () => {
|
||||
setPendingAction({ type: 'reset_password' });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
if (!confirmed) return;
|
||||
|
||||
const handleResendVerificationRequest = () => {
|
||||
setPendingAction({ type: 'resend_verification' });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
const { type } = pendingAction;
|
||||
setConfirmDialogOpen(false);
|
||||
|
||||
if (type === 'reset_password') {
|
||||
setResetPasswordLoading(true);
|
||||
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/reset-password`, {
|
||||
force_change: true
|
||||
@@ -76,10 +52,18 @@ const AdminUserView = () => {
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setResetPasswordLoading(false);
|
||||
setPendingAction(null);
|
||||
}
|
||||
} else if (type === 'resend_verification') {
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
const confirmed = window.confirm(
|
||||
`Resend verification email to ${user.email}?`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setResendVerificationLoading(true);
|
||||
|
||||
try {
|
||||
await api.post(`/admin/users/${userId}/resend-verification`);
|
||||
toast.success(`Verification email sent to ${user.email}`);
|
||||
@@ -90,36 +74,7 @@ const AdminUserView = () => {
|
||||
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>;
|
||||
@@ -186,7 +141,7 @@ const AdminUserView = () => {
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={handleResetPasswordRequest}
|
||||
onClick={handleResetPassword}
|
||||
disabled={resetPasswordLoading}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-4 py-2 disabled:opacity-50"
|
||||
@@ -197,7 +152,7 @@ const AdminUserView = () => {
|
||||
|
||||
{!user.email_verified && (
|
||||
<Button
|
||||
onClick={handleResendVerificationRequest}
|
||||
onClick={handleResendVerification}
|
||||
disabled={resendVerificationLoading}
|
||||
variant="outline"
|
||||
className="border-2 border-[#ff9e77] text-[#ff9e77] hover:bg-[#FFF3E0] rounded-full px-4 py-2 disabled:opacity-50"
|
||||
@@ -268,100 +223,10 @@ const AdminUserView = () => {
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Subscription Information
|
||||
</h2>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{/* TODO: Fetch and display subscription data */}
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Subscription details coming soon...</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Admin Action Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={confirmDialogOpen}
|
||||
onOpenChange={setConfirmDialogOpen}
|
||||
onConfirm={confirmAction}
|
||||
loading={resetPasswordLoading || resendVerificationLoading}
|
||||
{...getActionMessage()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
214
src/pages/admin/AdminUsers.js
Normal file
214
src/pages/admin/AdminUsers.js
Normal file
@@ -0,0 +1,214 @@
|
||||
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;
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../components/ui/dialog';
|
||||
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react';
|
||||
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
const MembersDirectory = () => {
|
||||
@@ -139,17 +139,6 @@ const MembersDirectory = () => {
|
||||
</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 */}
|
||||
<div className="space-y-3 mb-4">
|
||||
{member.directory_email && (
|
||||
|
||||
Reference in New Issue
Block a user