RBAC, Permissions, and Export/Import
This commit is contained in:
@@ -4,14 +4,15 @@ import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Users, Calendar, Clock, CheckCircle } from 'lucide-react';
|
||||
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle } from 'lucide-react';
|
||||
|
||||
const AdminDashboard = () => {
|
||||
const [stats, setStats] = useState({
|
||||
totalMembers: 0,
|
||||
pendingApprovals: 0,
|
||||
pendingValidations: 0,
|
||||
activeMembers: 0
|
||||
});
|
||||
const [usersNeedingAttention, setUsersNeedingAttention] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,11 +26,27 @@ const AdminDashboard = () => {
|
||||
|
||||
setStats({
|
||||
totalMembers: users.filter(u => u.role === 'member').length,
|
||||
pendingApprovals: users.filter(u =>
|
||||
['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(u.status)
|
||||
pendingValidations: users.filter(u =>
|
||||
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
|
||||
).length,
|
||||
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length
|
||||
});
|
||||
|
||||
// Find users who have received 3+ reminders (may need personal outreach)
|
||||
const needingAttention = users.filter(u => {
|
||||
const emailReminders = u.email_verification_reminders_sent || 0;
|
||||
const eventReminders = u.event_attendance_reminders_sent || 0;
|
||||
const paymentReminders = u.payment_reminders_sent || 0;
|
||||
const totalReminders = emailReminders + eventReminders + paymentReminders;
|
||||
return totalReminders >= 3;
|
||||
}).map(u => ({
|
||||
...u,
|
||||
totalReminders: (u.email_verification_reminders_sent || 0) +
|
||||
(u.event_attendance_reminders_sent || 0) +
|
||||
(u.payment_reminders_sent || 0)
|
||||
})).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5
|
||||
|
||||
setUsersNeedingAttention(needingAttention);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
} finally {
|
||||
@@ -62,16 +79,16 @@ const AdminDashboard = () => {
|
||||
<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-approvals">
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-validations">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="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.pendingApprovals}
|
||||
{loading ? '-' : stats.pendingValidations}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Approvals</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-active-members">
|
||||
@@ -89,42 +106,123 @@ const AdminDashboard = () => {
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<Link to="/admin/users">
|
||||
<Link to="/admin/members">
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
|
||||
<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 Users
|
||||
Manage Members
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View and manage all registered users and their membership status.
|
||||
View and manage paying members and their subscription status.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
data-testid="manage-users-button"
|
||||
>
|
||||
Go to Users
|
||||
Go to Members
|
||||
</Button>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<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" }}>
|
||||
Approval Queue
|
||||
Validation Queue
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review and approve pending membership applications.
|
||||
Review and validate pending membership applications.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
data-testid="manage-approvals-button"
|
||||
data-testid="manage-validations-button"
|
||||
>
|
||||
View Approvals
|
||||
View Validations
|
||||
</Button>
|
||||
</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,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -10,6 +11,7 @@ import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide
|
||||
import { AttendanceDialog } from '../../components/AttendanceDialog';
|
||||
|
||||
const AdminEvents = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
@@ -141,6 +143,7 @@ const AdminEvents = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(hasPermission('events.create') || hasPermission('events.edit')) && (
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
@@ -273,6 +276,7 @@ const AdminEvents = () => {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../components/ui/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import { Users, Search, User, CreditCard, Eye, CheckCircle } from 'lucide-react';
|
||||
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown } from 'lucide-react';
|
||||
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import CreateMemberDialog from '../../components/CreateMemberDialog';
|
||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||
import ImportMembersDialog from '../../components/ImportMembersDialog';
|
||||
|
||||
const AdminMembers = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { hasPermission } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -20,6 +32,13 @@ const AdminMembers = () => {
|
||||
const [statusFilter, setStatusFilter] = useState('active');
|
||||
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
|
||||
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
|
||||
const [statusChanging, setStatusChanging] = useState(null);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingStatusChange, setPendingStatusChange] = useState(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
@@ -70,14 +89,127 @@ const AdminMembers = () => {
|
||||
fetchMembers(); // Refresh list
|
||||
};
|
||||
|
||||
const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => {
|
||||
// Skip confirmation if status didn't actually change
|
||||
if (currentStatus === newStatus) return;
|
||||
|
||||
setPendingStatusChange({ userId, newStatus, user });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmStatusChange = async () => {
|
||||
if (!pendingStatusChange) return;
|
||||
|
||||
const { userId, newStatus } = pendingStatusChange;
|
||||
setStatusChanging(userId);
|
||||
setConfirmDialogOpen(false);
|
||||
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
|
||||
toast.success('Member status updated successfully');
|
||||
fetchMembers(); // Refresh list
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update status');
|
||||
} finally {
|
||||
setStatusChanging(null);
|
||||
setPendingStatusChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (filterType) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
let params = {};
|
||||
if (filterType === 'current') {
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
params.status = statusFilter;
|
||||
}
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
}
|
||||
}
|
||||
// filterType === 'all' will export all members without filters
|
||||
|
||||
const response = await api.get('/admin/users/export', {
|
||||
params,
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `members_export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
toast.success('Members exported successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to export members');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChangeMessage = () => {
|
||||
if (!pendingStatusChange) return {};
|
||||
|
||||
const { newStatus, user } = pendingStatusChange;
|
||||
const userName = `${user.first_name} ${user.last_name}`;
|
||||
|
||||
const messages = {
|
||||
payment_pending: {
|
||||
title: 'Revert to Payment Pending?',
|
||||
description: `This will change ${userName}'s status back to Payment Pending. They will need to complete payment again to become active.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Revert Status',
|
||||
},
|
||||
active: {
|
||||
title: 'Activate Member?',
|
||||
description: `This will activate ${userName}'s membership. They will gain full access to member features and resources.`,
|
||||
variant: 'success',
|
||||
confirmText: 'Yes, Activate',
|
||||
},
|
||||
inactive: {
|
||||
title: 'Deactivate Member?',
|
||||
description: `This will deactivate ${userName}'s membership. They will lose access to member-only features but their data will be preserved.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Deactivate',
|
||||
},
|
||||
canceled: {
|
||||
title: 'Cancel Membership?',
|
||||
description: `This will mark ${userName}'s membership as canceled. This indicates they voluntarily ended their membership. Their subscription will not auto-renew.`,
|
||||
variant: 'danger',
|
||||
confirmText: 'Yes, Cancel Membership',
|
||||
},
|
||||
expired: {
|
||||
title: 'Mark Membership as Expired?',
|
||||
description: `This will mark ${userName}'s membership as expired. This indicates their subscription period has ended without renewal.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Mark as Expired',
|
||||
},
|
||||
};
|
||||
|
||||
return messages[newStatus] || {
|
||||
title: 'Confirm Status Change',
|
||||
description: `Are you sure you want to change ${userName}'s status to ${newStatus}?`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Confirm',
|
||||
};
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const config = {
|
||||
pending_email: { label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
|
||||
pending_approval: { label: 'Pending Approval', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
|
||||
pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_validated: { label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
|
||||
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' },
|
||||
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
|
||||
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' }
|
||||
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' },
|
||||
canceled: { label: 'Canceled', className: 'bg-red-100 text-red-700' },
|
||||
expired: { label: 'Expired', className: 'bg-red-500 text-white' },
|
||||
abandoned: { label: 'Abandoned', className: 'bg-gray-300 text-gray-600' }
|
||||
};
|
||||
|
||||
const statusConfig = config[status] || config.inactive;
|
||||
@@ -88,15 +220,102 @@ const AdminMembers = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getReminderInfo = (user) => {
|
||||
const emailReminders = user.email_verification_reminders_sent || 0;
|
||||
const eventReminders = user.event_attendance_reminders_sent || 0;
|
||||
const paymentReminders = user.payment_reminders_sent || 0;
|
||||
const renewalReminders = user.renewal_reminders_sent || 0;
|
||||
const totalReminders = emailReminders + eventReminders + paymentReminders + renewalReminders;
|
||||
|
||||
return {
|
||||
emailReminders,
|
||||
eventReminders,
|
||||
paymentReminders,
|
||||
renewalReminders,
|
||||
totalReminders,
|
||||
lastReminderAt: user.last_email_verification_reminder_at ||
|
||||
user.last_event_attendance_reminder_at ||
|
||||
user.last_payment_reminder_at ||
|
||||
user.last_renewal_reminder_at
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Members Management
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage paying members and their subscriptions.
|
||||
</p>
|
||||
<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>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage paying members and their subscriptions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{hasPermission('users.export') && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? (
|
||||
<>
|
||||
<Download className="h-5 w-5 mr-2 animate-bounce" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileDown className="h-5 w-5 mr-2" />
|
||||
Export
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="rounded-xl">
|
||||
<DropdownMenuItem onClick={() => handleExport('all')} className="cursor-pointer">
|
||||
Export All Members
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleExport('current')} className="cursor-pointer">
|
||||
Export Current View
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{hasPermission('users.import') && (
|
||||
<Button
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
className="bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-xl h-12 px-6"
|
||||
>
|
||||
<Upload className="h-5 w-5 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasPermission('users.invite') && (
|
||||
<Button
|
||||
onClick={() => setInviteDialogOpen(true)}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
|
||||
>
|
||||
<Mail className="h-5 w-5 mr-2" />
|
||||
Invite Member
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasPermission('users.create') && (
|
||||
<Button
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-xl h-12 px-6"
|
||||
>
|
||||
<UserPlus className="h-5 w-5 mr-2" />
|
||||
Create Member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -148,9 +367,12 @@ const AdminMembers = () => {
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||
<SelectItem value="pending_approval">Pending Approval</SelectItem>
|
||||
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
|
||||
<SelectItem value="pending_validation">Pending Validation</SelectItem>
|
||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="canceled">Canceled</SelectItem>
|
||||
<SelectItem value="expired">Expired</SelectItem>
|
||||
<SelectItem value="abandoned">Abandoned</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -192,45 +414,112 @@ 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 gap-2">
|
||||
<Link to={`/admin/users/${user.id}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
View Profile
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Link to={`/admin/users/${user.id}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
View Profile
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Show Activate Payment button for payment_pending users */}
|
||||
{user.status === 'payment_pending' && (
|
||||
<Button
|
||||
onClick={() => handleActivatePayment(user)}
|
||||
size="sm"
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Activate Payment
|
||||
</Button>
|
||||
)}
|
||||
{/* Show Activate Payment button for payment_pending users */}
|
||||
{user.status === 'payment_pending' && (
|
||||
<Button
|
||||
onClick={() => handleActivatePayment(user)}
|
||||
size="sm"
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Activate Payment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
{/* 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}
|
||||
>
|
||||
<CreditCard className="h-4 w-4 mr-1" />
|
||||
Subscription
|
||||
</Button>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -257,6 +546,34 @@ 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
415
src/pages/admin/AdminPermissions.js
Normal file
415
src/pages/admin/AdminPermissions.js
Normal file
@@ -0,0 +1,415 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Checkbox } from '../../components/ui/checkbox';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '../../components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { Shield, Save, Lock, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const AdminPermissions = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [rolePermissions, setRolePermissions] = useState({
|
||||
admin: [],
|
||||
member: [],
|
||||
guest: []
|
||||
});
|
||||
const [selectedPermissions, setSelectedPermissions] = useState({
|
||||
admin: [],
|
||||
member: [],
|
||||
guest: []
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState('admin');
|
||||
const [expandedModules, setExpandedModules] = useState({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermissions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if there are unsaved changes
|
||||
const changed = ['admin', 'member', 'guest'].some(role => {
|
||||
const current = selectedPermissions[role].slice().sort();
|
||||
const original = rolePermissions[role].slice().sort();
|
||||
return JSON.stringify(current) !== JSON.stringify(original);
|
||||
});
|
||||
setHasChanges(changed);
|
||||
}, [selectedPermissions, rolePermissions]);
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
// Fetch all permissions
|
||||
const permsResponse = await api.get('/admin/permissions');
|
||||
setPermissions(permsResponse.data);
|
||||
|
||||
// Fetch permissions for each role
|
||||
const roles = ['admin', 'member', 'guest'];
|
||||
const rolePermsData = {};
|
||||
const selectedPermsData = {};
|
||||
|
||||
for (const role of roles) {
|
||||
const response = await api.get(`/admin/permissions/roles/${role}`);
|
||||
rolePermsData[role] = response.data.map(p => p.code);
|
||||
selectedPermsData[role] = response.data.map(p => p.code);
|
||||
}
|
||||
|
||||
setRolePermissions(rolePermsData);
|
||||
setSelectedPermissions(selectedPermsData);
|
||||
|
||||
// Expand all modules by default
|
||||
const modules = [...new Set(permsResponse.data.map(p => p.module))];
|
||||
const expanded = {};
|
||||
modules.forEach(module => {
|
||||
expanded[module] = true;
|
||||
});
|
||||
setExpandedModules(expanded);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch permissions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (role, permissionCode) => {
|
||||
setSelectedPermissions(prev => {
|
||||
const current = prev[role] || [];
|
||||
if (current.includes(permissionCode)) {
|
||||
return {
|
||||
...prev,
|
||||
[role]: current.filter(p => p !== permissionCode)
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
[role]: [...current, permissionCode]
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleModule = (role, module) => {
|
||||
const modulePerms = permissions
|
||||
.filter(p => p.module === module)
|
||||
.map(p => p.code);
|
||||
|
||||
const allSelected = modulePerms.every(code =>
|
||||
selectedPermissions[role].includes(code)
|
||||
);
|
||||
|
||||
if (allSelected) {
|
||||
// Deselect all module permissions
|
||||
setSelectedPermissions(prev => ({
|
||||
...prev,
|
||||
[role]: prev[role].filter(p => !modulePerms.includes(p))
|
||||
}));
|
||||
} else {
|
||||
// Select all module permissions
|
||||
setSelectedPermissions(prev => ({
|
||||
...prev,
|
||||
[role]: [...new Set([...prev[role], ...modulePerms])]
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const confirmSave = async () => {
|
||||
setSaving(true);
|
||||
setShowConfirmDialog(false);
|
||||
|
||||
try {
|
||||
// Save permissions for each role
|
||||
await Promise.all([
|
||||
api.put(`/admin/permissions/roles/${selectedRole}`, {
|
||||
permission_codes: selectedPermissions[selectedRole]
|
||||
})
|
||||
]);
|
||||
|
||||
// Update original state
|
||||
setRolePermissions(prev => ({
|
||||
...prev,
|
||||
[selectedRole]: [...selectedPermissions[selectedRole]]
|
||||
}));
|
||||
|
||||
toast.success(`Permissions updated for ${selectedRole}`);
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update permissions');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleModuleExpansion = (module) => {
|
||||
setExpandedModules(prev => ({
|
||||
...prev,
|
||||
[module]: !prev[module]
|
||||
}));
|
||||
};
|
||||
|
||||
const groupedPermissions = permissions.reduce((acc, perm) => {
|
||||
if (!acc[perm.module]) {
|
||||
acc[perm.module] = [];
|
||||
}
|
||||
acc[perm.module].push(perm);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const getModuleProgress = (role, module) => {
|
||||
const modulePerms = groupedPermissions[module] || [];
|
||||
const selected = modulePerms.filter(p =>
|
||||
selectedPermissions[role].includes(p.code)
|
||||
).length;
|
||||
return `${selected}/${modulePerms.length}`;
|
||||
};
|
||||
|
||||
const isModuleFullySelected = (role, module) => {
|
||||
const modulePerms = groupedPermissions[module] || [];
|
||||
return modulePerms.every(p => selectedPermissions[role].includes(p.code));
|
||||
};
|
||||
|
||||
const getRoleBadge = (role) => {
|
||||
const config = {
|
||||
admin: { label: 'Admin', color: 'bg-[#81B29A]', icon: Shield },
|
||||
member: { label: 'Member', color: 'bg-[#664fa3]', icon: Shield },
|
||||
guest: { label: 'Guest', color: 'bg-gray-400', icon: Shield }
|
||||
};
|
||||
|
||||
const roleConfig = config[role] || config.admin;
|
||||
const Icon = roleConfig.icon;
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 ${roleConfig.color} text-white px-4 py-2 rounded-full`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="font-semibold">{roleConfig.label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Loading permissions...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasPermission('permissions.assign')) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<Lock className="h-20 w-20 text-red-500 mx-auto mb-6" />
|
||||
<h2 className="text-3xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You don't have permission to manage role permissions.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Only Superadmins can access this page.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Permission Management
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Configure granular permissions for each role. Superadmin always has all permissions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Role Tabs */}
|
||||
<Tabs value={selectedRole} onValueChange={setSelectedRole} className="mb-8">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-8">
|
||||
<TabsTrigger value="admin" className="text-lg py-3">
|
||||
{getRoleBadge('admin')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="member" className="text-lg py-3">
|
||||
{getRoleBadge('member')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="guest" className="text-lg py-3">
|
||||
{getRoleBadge('guest')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{['admin', 'member', 'guest'].map(role => (
|
||||
<TabsContent key={role} value={role}>
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-8">
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Total Permissions
|
||||
</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{permissions.length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Assigned
|
||||
</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{selectedPermissions[role].length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Modules
|
||||
</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{Object.keys(groupedPermissions).length}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Permissions by Module */}
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedPermissions).map(([module, perms]) => (
|
||||
<Card key={module} className="bg-white rounded-2xl border border-[#ddd8eb] overflow-hidden">
|
||||
{/* Module Header */}
|
||||
<div
|
||||
className="p-6 bg-gradient-to-r from-[#DDD8EB] to-white cursor-pointer hover:from-[#C5BFD9] transition-colors"
|
||||
onClick={() => toggleModuleExpansion(module)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Checkbox
|
||||
checked={isModuleFullySelected(role, module)}
|
||||
onCheckedChange={() => toggleModule(role, module)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-6 w-6 border-2 border-[#664fa3] data-[state=checked]:bg-[#664fa3]"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-[#422268] capitalize" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{module}
|
||||
</h3>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{getModuleProgress(role, module)} permissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{expandedModules[module] ? (
|
||||
<ChevronUp className="h-6 w-6 text-[#664fa3]" />
|
||||
) : (
|
||||
<ChevronDown className="h-6 w-6 text-[#664fa3]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module Permissions */}
|
||||
{expandedModules[module] && (
|
||||
<div className="p-6 pt-0">
|
||||
<div className="grid md:grid-cols-2 gap-4 mt-4">
|
||||
{perms.map(perm => (
|
||||
<div
|
||||
key={perm.code}
|
||||
className="flex items-start gap-4 p-4 rounded-xl hover:bg-[#F9F8FB] transition-colors border border-transparent hover:border-[#ddd8eb]"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedPermissions[role].includes(perm.code)}
|
||||
onCheckedChange={() => togglePermission(role, perm.code)}
|
||||
className="mt-1 h-5 w-5 border-2 border-[#664fa3] data-[state=checked]:bg-[#664fa3]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{perm.name}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{perm.description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1 font-mono">
|
||||
{perm.code}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* Superadmin Note */}
|
||||
<Card className="p-6 bg-gradient-to-r from-[#664fa3] to-[#422268] rounded-2xl border-none mb-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<Lock className="h-6 w-6 text-white flex-shrink-0 mt-1" />
|
||||
<div className="text-white">
|
||||
<p className="font-semibold mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Superadmin Permissions
|
||||
</p>
|
||||
<p className="text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Superadmins automatically have all permissions and cannot be restricted. This ensures you can never lock yourself out of the system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="h-14 px-8 bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-full shadow-lg text-lg font-semibold"
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent className="rounded-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Confirm Permission Changes
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Are you sure you want to update permissions for <span className="font-semibold capitalize">{selectedRole}</span>?
|
||||
This will immediately affect all users with this role.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="rounded-xl">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmSave}
|
||||
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPermissions;
|
||||
498
src/pages/admin/AdminRoles.js
Normal file
498
src/pages/admin/AdminRoles.js
Normal file
@@ -0,0 +1,498 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Textarea } from '../../components/ui/textarea';
|
||||
import { Checkbox } from '../../components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../../components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '../../components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { Shield, Plus, Edit, Trash2, Lock, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const AdminRoles = () => {
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [selectedRole, setSelectedRole] = useState(null);
|
||||
const [rolePermissions, setRolePermissions] = useState([]);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showPermissionsModal, setShowPermissionsModal] = useState(false);
|
||||
const [expandedModules, setExpandedModules] = useState({});
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
fetchPermissions();
|
||||
}, []);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/roles');
|
||||
setRoles(response.data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch roles');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/permissions');
|
||||
setPermissions(response.data);
|
||||
|
||||
// Expand all modules by default
|
||||
const modules = [...new Set(response.data.map(p => p.module))];
|
||||
const expanded = {};
|
||||
modules.forEach(module => {
|
||||
expanded[module] = true;
|
||||
});
|
||||
setExpandedModules(expanded);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch permissions');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRolePermissions = async (roleId) => {
|
||||
try {
|
||||
const response = await api.get(`/admin/roles/${roleId}/permissions`);
|
||||
setRolePermissions(response.data.permissions);
|
||||
setSelectedPermissions(response.data.permissions.map(p => p.code));
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch role permissions');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRole = async () => {
|
||||
try {
|
||||
await api.post('/admin/roles', {
|
||||
code: formData.code,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
permission_codes: formData.permissions
|
||||
});
|
||||
toast.success('Role created successfully');
|
||||
setShowCreateModal(false);
|
||||
setFormData({ code: '', name: '', description: '', permissions: [] });
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to create role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async () => {
|
||||
try {
|
||||
await api.put(`/admin/roles/${selectedRole.id}`, {
|
||||
name: formData.name,
|
||||
description: formData.description
|
||||
});
|
||||
toast.success('Role updated successfully');
|
||||
setShowEditModal(false);
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRole = async () => {
|
||||
try {
|
||||
await api.delete(`/admin/roles/${selectedRole.id}`);
|
||||
toast.success('Role deleted successfully');
|
||||
setShowDeleteDialog(false);
|
||||
setSelectedRole(null);
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to delete role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
try {
|
||||
await api.put(`/admin/roles/${selectedRole.id}/permissions`, {
|
||||
permission_codes: selectedPermissions
|
||||
});
|
||||
toast.success('Permissions updated successfully');
|
||||
setShowPermissionsModal(false);
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
toast.error('Failed to update permissions');
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (permissionCode) => {
|
||||
setSelectedPermissions(prev => {
|
||||
if (prev.includes(permissionCode)) {
|
||||
return prev.filter(p => p !== permissionCode);
|
||||
} else {
|
||||
return [...prev, permissionCode];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleModule = (module) => {
|
||||
setExpandedModules(prev => ({
|
||||
...prev,
|
||||
[module]: !prev[module]
|
||||
}));
|
||||
};
|
||||
|
||||
const groupPermissionsByModule = () => {
|
||||
const grouped = {};
|
||||
permissions.forEach(perm => {
|
||||
if (!grouped[perm.module]) {
|
||||
grouped[perm.module] = [];
|
||||
}
|
||||
grouped[perm.module].push(perm);
|
||||
});
|
||||
return grouped;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-gray-500">Loading roles...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groupedPermissions = groupPermissionsByModule();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Role Management</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Create and manage custom roles with specific permissions
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Role
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Roles Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{roles.map(role => (
|
||||
<Card key={role.id} className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<Shield className="w-5 h-5 text-blue-600 mr-2" />
|
||||
<div>
|
||||
<h3 className="font-semibold">{role.name}</h3>
|
||||
<p className="text-sm text-gray-500">{role.code}</p>
|
||||
</div>
|
||||
</div>
|
||||
{role.is_system_role && (
|
||||
<Lock className="w-4 h-4 text-gray-400" title="System Role" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4 min-h-[40px]">
|
||||
{role.description || 'No description'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-gray-500">
|
||||
{role.permission_count} permissions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedRole(role);
|
||||
fetchRolePermissions(role.id);
|
||||
setShowPermissionsModal(true);
|
||||
}}
|
||||
>
|
||||
Manage Permissions
|
||||
</Button>
|
||||
{!role.is_system_role && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedRole(role);
|
||||
setFormData({
|
||||
name: role.name,
|
||||
description: role.description || ''
|
||||
});
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setSelectedRole(role);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create Role Modal */}
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Role</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a custom role with specific permissions for your team members.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Role Code *</Label>
|
||||
<Input
|
||||
placeholder="e.g., content_editor, finance_manager"
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Lowercase, no spaces. Used internally to identify the role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Role Name *</Label>
|
||||
<Input
|
||||
placeholder="e.g., Content Editor, Finance Manager"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
placeholder="Describe what this role can do..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Permissions</Label>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Select permissions for this role. You can also add permissions later.
|
||||
</p>
|
||||
<div className="border rounded-lg p-4 max-h-64 overflow-y-auto">
|
||||
{Object.entries(groupedPermissions).map(([module, perms]) => (
|
||||
<div key={module} className="mb-4">
|
||||
<button
|
||||
onClick={() => toggleModule(module)}
|
||||
className="flex items-center w-full text-left font-medium mb-2 hover:text-blue-600"
|
||||
>
|
||||
{expandedModules[module] ? (
|
||||
<ChevronUp className="w-4 h-4 mr-1" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
|
||||
</button>
|
||||
{expandedModules[module] && (
|
||||
<div className="space-y-2 ml-5">
|
||||
{perms.map(perm => (
|
||||
<div key={perm.code} className="flex items-center">
|
||||
<Checkbox
|
||||
checked={formData.permissions.includes(perm.code)}
|
||||
onCheckedChange={() => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: prev.permissions.includes(perm.code)
|
||||
? prev.permissions.filter(p => p !== perm.code)
|
||||
: [...prev.permissions, perm.code]
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<label className="ml-2 text-sm">
|
||||
<span className="font-medium">{perm.name}</span>
|
||||
<span className="text-gray-500 ml-2">({perm.code})</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateRole}>
|
||||
Create Role
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Role Modal */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Role</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update role name and description. Code cannot be changed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Role Code</Label>
|
||||
<Input value={selectedRole?.code || ''} disabled />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Role Name *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowEditModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateRole}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Manage Permissions Modal */}
|
||||
<Dialog open={showPermissionsModal} onOpenChange={setShowPermissionsModal}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Permissions: {selectedRole?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select which permissions this role should have.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border rounded-lg p-4">
|
||||
{Object.entries(groupedPermissions).map(([module, perms]) => (
|
||||
<div key={module} className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleModule(module)}
|
||||
className="flex items-center w-full text-left font-medium text-lg mb-3 hover:text-blue-600"
|
||||
>
|
||||
{expandedModules[module] ? (
|
||||
<ChevronUp className="w-5 h-5 mr-2" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 mr-2" />
|
||||
)}
|
||||
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
|
||||
</button>
|
||||
{expandedModules[module] && (
|
||||
<div className="space-y-3 ml-7">
|
||||
{perms.map(perm => (
|
||||
<div key={perm.code} className="flex items-start">
|
||||
<Checkbox
|
||||
checked={selectedPermissions.includes(perm.code)}
|
||||
onCheckedChange={() => togglePermission(perm.code)}
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<label className="font-medium text-sm">
|
||||
{perm.name}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">{perm.description}</p>
|
||||
<span className="text-xs text-gray-400">{perm.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowPermissionsModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSavePermissions}>
|
||||
Save Permissions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Role?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the role "{selectedRole?.name}"?
|
||||
This action cannot be undone. Users with this role will need to be reassigned.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteRole} className="bg-red-600">
|
||||
Delete Role
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminRoles;
|
||||
@@ -1,22 +1,31 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
|
||||
import CreateStaffDialog from '../../components/CreateStaffDialog';
|
||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||
import PendingInvitationsTable from '../../components/PendingInvitationsTable';
|
||||
import { toast } from 'sonner';
|
||||
import { UserCog, Search, Shield } from 'lucide-react';
|
||||
import { UserCog, Search, Shield, UserPlus, Mail } from 'lucide-react';
|
||||
|
||||
const AdminStaff = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('all');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('staff-list');
|
||||
|
||||
// Staff roles (non-guest, non-member)
|
||||
const STAFF_ROLES = ['admin'];
|
||||
// Staff roles (non-guest, non-member) - includes all admin-type roles
|
||||
const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
|
||||
|
||||
useEffect(() => {
|
||||
fetchStaff();
|
||||
@@ -95,12 +104,36 @@ const AdminStaff = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Staff Management
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage internal team members and their roles.
|
||||
</p>
|
||||
<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>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage internal team members and their roles.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{hasPermission('users.invite') && (
|
||||
<Button
|
||||
onClick={() => setInviteDialogOpen(true)}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
|
||||
>
|
||||
<Mail className="h-5 w-5 mr-2" />
|
||||
Invite Staff
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('users.create') && (
|
||||
<Button
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-xl h-12 px-6"
|
||||
>
|
||||
<UserPlus className="h-5 w-5 mr-2" />
|
||||
Create Staff
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -131,91 +164,127 @@ const AdminStaff = () => {
|
||||
</Card>
|
||||
</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-staff-input"
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[#ddd8eb]" data-testid="role-filter-select">
|
||||
<SelectValue placeholder="Filter by role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Roles</SelectItem>
|
||||
<SelectItem value="superadmin">Superadmin</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="moderator">Moderator</SelectItem>
|
||||
<SelectItem value="staff">Staff</SelectItem>
|
||||
<SelectItem value="media">Media</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
{/* 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>
|
||||
|
||||
{/* Staff List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading staff...</p>
|
||||
</div>
|
||||
) : filteredUsers.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredUsers.map((user) => (
|
||||
<Card
|
||||
key={user.id}
|
||||
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-md transition-shadow"
|
||||
data-testid={`staff-card-${user.id}`}
|
||||
>
|
||||
<div className="flex justify-between items-start flex-wrap gap-4">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
{/* Avatar */}
|
||||
<div className="h-14 w-14 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold text-lg flex-shrink-0">
|
||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<h3 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</h3>
|
||||
{getRoleBadge(user.role)}
|
||||
{getStatusBadge(user.status)}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p>Email: {user.email}</p>
|
||||
<p>Phone: {user.phone}</p>
|
||||
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
|
||||
{user.last_login && (
|
||||
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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-staff-input"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<UserCog className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Staff Found
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{searchQuery || roleFilter !== 'all'
|
||||
? 'Try adjusting your filters'
|
||||
: 'No staff members yet'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[#ddd8eb]" data-testid="role-filter-select">
|
||||
<SelectValue placeholder="Filter by role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Roles</SelectItem>
|
||||
<SelectItem value="superadmin">Superadmin</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="moderator">Moderator</SelectItem>
|
||||
<SelectItem value="staff">Staff</SelectItem>
|
||||
<SelectItem value="media">Media</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Staff List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading staff...</p>
|
||||
</div>
|
||||
) : filteredUsers.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredUsers.map((user) => (
|
||||
<Card
|
||||
key={user.id}
|
||||
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-md transition-shadow"
|
||||
data-testid={`staff-card-${user.id}`}
|
||||
>
|
||||
<div className="flex justify-between items-start flex-wrap gap-4">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
{/* Avatar */}
|
||||
<div className="h-14 w-14 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold text-lg flex-shrink-0">
|
||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<h3 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</h3>
|
||||
{getRoleBadge(user.role)}
|
||||
{getStatusBadge(user.status)}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p>Email: {user.email}</p>
|
||||
<p>Phone: {user.phone}</p>
|
||||
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
|
||||
{user.last_login && (
|
||||
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<UserCog className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Staff Found
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{searchQuery || roleFilter !== 'all'
|
||||
? 'Try adjusting your filters'
|
||||
: 'No staff members yet'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pending-invitations">
|
||||
<PendingInvitationsTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Dialogs */}
|
||||
<CreateStaffDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={fetchStaff}
|
||||
/>
|
||||
|
||||
<InviteStaffDialog
|
||||
open={inviteDialogOpen}
|
||||
onOpenChange={setInviteDialogOpen}
|
||||
onSuccess={() => {
|
||||
// Optionally refresh invitations table
|
||||
setActiveTab('pending-invitations');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
|
||||
const AdminUserView = () => {
|
||||
const { userId } = useParams();
|
||||
@@ -14,9 +15,14 @@ const AdminUserView = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||
const [resendVerificationLoading, setResendVerificationLoading] = useState(false);
|
||||
const [subscriptions, setSubscriptions] = useState([]);
|
||||
const [subscriptionsLoading, setSubscriptionsLoading] = useState(true);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserProfile();
|
||||
fetchSubscriptions();
|
||||
}, [userId]);
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
@@ -31,52 +37,91 @@ const AdminUserView = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
const confirmed = window.confirm(
|
||||
`Reset password for ${user.first_name} ${user.last_name}?\n\n` +
|
||||
`A temporary password will be emailed to ${user.email}.\n` +
|
||||
`They will be required to change it on next login.`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setResetPasswordLoading(true);
|
||||
|
||||
const fetchSubscriptions = async () => {
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/reset-password`, {
|
||||
force_change: true
|
||||
});
|
||||
toast.success(`Password reset email sent to ${user.email}`);
|
||||
const response = await api.get(`/admin/subscriptions?user_id=${userId}`);
|
||||
setSubscriptions(response.data);
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to reset password';
|
||||
toast.error(errorMessage);
|
||||
console.error('Failed to fetch subscriptions:', error);
|
||||
} finally {
|
||||
setResetPasswordLoading(false);
|
||||
setSubscriptionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
const confirmed = window.confirm(
|
||||
`Resend verification email to ${user.email}?`
|
||||
);
|
||||
const handleResetPasswordRequest = () => {
|
||||
setPendingAction({ type: 'reset_password' });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
if (!confirmed) return;
|
||||
const handleResendVerificationRequest = () => {
|
||||
setPendingAction({ type: 'resend_verification' });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
setResendVerificationLoading(true);
|
||||
const confirmAction = async () => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
try {
|
||||
await api.post(`/admin/users/${userId}/resend-verification`);
|
||||
toast.success(`Verification email sent to ${user.email}`);
|
||||
// Refresh user data to get updated email_verified status if changed
|
||||
await fetchUserProfile();
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setResendVerificationLoading(false);
|
||||
const { type } = pendingAction;
|
||||
setConfirmDialogOpen(false);
|
||||
|
||||
if (type === 'reset_password') {
|
||||
setResetPasswordLoading(true);
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/reset-password`, {
|
||||
force_change: true
|
||||
});
|
||||
toast.success(`Password reset email sent to ${user.email}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to reset password';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setResetPasswordLoading(false);
|
||||
setPendingAction(null);
|
||||
}
|
||||
} else if (type === 'resend_verification') {
|
||||
setResendVerificationLoading(true);
|
||||
try {
|
||||
await api.post(`/admin/users/${userId}/resend-verification`);
|
||||
toast.success(`Verification email sent to ${user.email}`);
|
||||
// Refresh user data to get updated email_verified status if changed
|
||||
await fetchUserProfile();
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setResendVerificationLoading(false);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getActionMessage = () => {
|
||||
if (!pendingAction || !user) return {};
|
||||
|
||||
const { type } = pendingAction;
|
||||
const userName = `${user.first_name} ${user.last_name}`;
|
||||
|
||||
if (type === 'reset_password') {
|
||||
return {
|
||||
title: 'Reset Password?',
|
||||
description: `This will send a temporary password to ${user.email}. ${userName} will be required to change it on their next login.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Reset Password',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'resend_verification') {
|
||||
return {
|
||||
title: 'Resend Verification Email?',
|
||||
description: `This will send a new verification email to ${user.email}. ${userName} will need to click the link to verify their email address.`,
|
||||
variant: 'info',
|
||||
confirmText: 'Yes, Resend Email',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (!user) return null;
|
||||
|
||||
@@ -141,7 +186,7 @@ const AdminUserView = () => {
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={handleResetPassword}
|
||||
onClick={handleResetPasswordRequest}
|
||||
disabled={resetPasswordLoading}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-4 py-2 disabled:opacity-50"
|
||||
@@ -152,7 +197,7 @@ const AdminUserView = () => {
|
||||
|
||||
{!user.email_verified && (
|
||||
<Button
|
||||
onClick={handleResendVerification}
|
||||
onClick={handleResendVerificationRequest}
|
||||
disabled={resendVerificationLoading}
|
||||
variant="outline"
|
||||
className="border-2 border-[#ff9e77] text-[#ff9e77] hover:bg-[#FFF3E0] rounded-full px-4 py-2 disabled:opacity-50"
|
||||
@@ -223,10 +268,100 @@ const AdminUserView = () => {
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Subscription Information
|
||||
</h2>
|
||||
{/* TODO: Fetch and display subscription data */}
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Subscription details coming soon...</p>
|
||||
|
||||
{subscriptionsLoading ? (
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading subscriptions...</p>
|
||||
) : subscriptions.length === 0 ? (
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No subscriptions found for this member.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{subscriptions.map((sub) => (
|
||||
<div key={sub.id} className="p-6 bg-[#F8F7FB] rounded-xl border border-[#ddd8eb]">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{sub.plan.name}
|
||||
</h3>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{sub.plan.billing_cycle}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={
|
||||
sub.status === 'active' ? 'bg-[#81B29A] text-white' :
|
||||
sub.status === 'expired' ? 'bg-red-500 text-white' :
|
||||
'bg-gray-400 text-white'
|
||||
}>
|
||||
{sub.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Start Date</label>
|
||||
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(sub.start_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
{sub.end_date && (
|
||||
<div>
|
||||
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>End Date</label>
|
||||
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(sub.end_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Base Amount</label>
|
||||
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
${(sub.base_subscription_cents / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
{sub.donation_cents > 0 && (
|
||||
<div>
|
||||
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Donation</label>
|
||||
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
${(sub.donation_cents / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Paid</label>
|
||||
<p className="text-[#422268] font-semibold" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
${(sub.amount_paid_cents / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
{sub.payment_method && (
|
||||
<div>
|
||||
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Method</label>
|
||||
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{sub.payment_method}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{sub.stripe_subscription_id && (
|
||||
<div className="md:col-span-2">
|
||||
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Stripe Subscription ID</label>
|
||||
<p className="text-[#422268] text-xs font-mono" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{sub.stripe_subscription_id}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Admin Action Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={confirmDialogOpen}
|
||||
onOpenChange={setConfirmDialogOpen}
|
||||
onConfirm={confirmAction}
|
||||
loading={resetPasswordLoading || resendVerificationLoading}
|
||||
{...getActionMessage()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { Users, Search, CheckCircle, Clock, Mail, Eye } from 'lucide-react';
|
||||
|
||||
const AdminUsers = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [resendingUserId, setResendingUserId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterUsers();
|
||||
}, [users, searchQuery, statusFilter]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/users');
|
||||
setUsers(response.data);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterUsers = () => {
|
||||
let filtered = users;
|
||||
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
filtered = filtered.filter(user => user.status === statusFilter);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(user =>
|
||||
user.first_name.toLowerCase().includes(query) ||
|
||||
user.last_name.toLowerCase().includes(query) ||
|
||||
user.email.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredUsers(filtered);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const config = {
|
||||
pending_email: { label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
|
||||
pending_approval: { label: 'Pending Approval', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
|
||||
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' },
|
||||
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
|
||||
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' }
|
||||
};
|
||||
|
||||
const statusConfig = config[status] || config.inactive;
|
||||
return (
|
||||
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleAdminResendVerification = async (userId, userEmail) => {
|
||||
const confirmed = window.confirm(
|
||||
`Resend verification email to ${userEmail}?`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setResendingUserId(userId);
|
||||
try {
|
||||
await api.post(`/admin/users/${userId}/resend-verification`);
|
||||
toast.success(`Verification email sent to ${userEmail}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setResendingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
User Management
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View and manage all registered users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
||||
<Input
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-12 h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
data-testid="search-users-input"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[#ddd8eb]" data-testid="status-filter-select">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="pending_email">Pending Email</SelectItem>
|
||||
<SelectItem value="pending_approval">Pending Approval</SelectItem>
|
||||
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
|
||||
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Users List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading users...</p>
|
||||
</div>
|
||||
) : filteredUsers.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredUsers.map((user) => (
|
||||
<Card
|
||||
key={user.id}
|
||||
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-md transition-shadow"
|
||||
data-testid={`user-card-${user.id}`}
|
||||
>
|
||||
<div className="flex justify-between items-start flex-wrap gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</h3>
|
||||
{getStatusBadge(user.status)}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p>Email: {user.email}</p>
|
||||
<p>Phone: {user.phone}</p>
|
||||
<p>Role: <span className="capitalize">{user.role}</span></p>
|
||||
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
|
||||
{user.referred_by_member_name && (
|
||||
<p className="col-span-2">Referred by: {user.referred_by_member_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#664fa3] hover:text-[#422268]"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
|
||||
{!user.email_verified && (
|
||||
<Button
|
||||
onClick={() => handleAdminResendVerification(user.id, user.email)}
|
||||
disabled={resendingUserId === user.id}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#ff9e77] hover:text-[#664fa3]"
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-1" />
|
||||
{resendingUserId === user.id ? 'Sending...' : 'Resend Verification'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<Users className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Users Found
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
? 'Try adjusting your filters'
|
||||
: 'No users registered yet'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsers;
|
||||
@@ -31,14 +31,17 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
|
||||
const AdminApprovals = () => {
|
||||
const AdminValidations = () => {
|
||||
const [pendingUsers, setPendingUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(null);
|
||||
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
|
||||
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState(null);
|
||||
|
||||
// Filtering state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -68,7 +71,7 @@ const AdminApprovals = () => {
|
||||
try {
|
||||
const response = await api.get('/admin/users');
|
||||
const pending = response.data.filter(user =>
|
||||
['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(user.status)
|
||||
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(user.status)
|
||||
);
|
||||
setPendingUsers(pending);
|
||||
} catch (error) {
|
||||
@@ -120,37 +123,65 @@ const AdminApprovals = () => {
|
||||
setFilteredUsers(filtered);
|
||||
};
|
||||
|
||||
const handleApprove = async (userId) => {
|
||||
setActionLoading(userId);
|
||||
const handleValidateRequest = (user) => {
|
||||
setPendingAction({ type: 'validate', user });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleBypassAndValidateRequest = (user) => {
|
||||
setPendingAction({ type: 'bypass_and_validate', user });
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
const { type, user } = pendingAction;
|
||||
setActionLoading(user.id);
|
||||
setConfirmDialogOpen(false);
|
||||
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/approve`);
|
||||
toast.success('User validated and approved! Payment email sent.');
|
||||
if (type === 'validate') {
|
||||
await api.put(`/admin/users/${user.id}/validate`);
|
||||
toast.success('User validated! Payment email sent.');
|
||||
} else if (type === 'bypass_and_validate') {
|
||||
await api.put(`/admin/users/${user.id}/validate?bypass_email_verification=true`);
|
||||
toast.success('User email verified and validated! Payment email sent.');
|
||||
}
|
||||
fetchPendingUsers();
|
||||
} catch (error) {
|
||||
toast.error('Failed to approve user');
|
||||
toast.error(error.response?.data?.detail || 'Failed to validate user');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBypassAndApprove = async (userId) => {
|
||||
if (!window.confirm(
|
||||
'This will bypass email verification and approve the user. ' +
|
||||
'Are you sure you want to proceed?'
|
||||
)) {
|
||||
return;
|
||||
const getActionMessage = () => {
|
||||
if (!pendingAction) return {};
|
||||
|
||||
const { type, user } = pendingAction;
|
||||
const userName = `${user.first_name} ${user.last_name}`;
|
||||
|
||||
if (type === 'validate') {
|
||||
return {
|
||||
title: 'Validate User?',
|
||||
description: `This will validate ${userName} and send them a payment link email. They will be able to complete payment and become an active member.`,
|
||||
variant: 'success',
|
||||
confirmText: 'Yes, Validate User',
|
||||
};
|
||||
}
|
||||
|
||||
setActionLoading(userId);
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/approve?bypass_email_verification=true`);
|
||||
toast.success('User email verified and approved! Payment email sent.');
|
||||
fetchPendingUsers();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to approve user');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
if (type === 'bypass_and_validate') {
|
||||
return {
|
||||
title: 'Bypass Email & Validate User?',
|
||||
description: `This will bypass email verification for ${userName} and validate them immediately. A payment link email will be sent. Use this only if you've confirmed their email through other means.`,
|
||||
variant: 'warning',
|
||||
confirmText: 'Yes, Bypass & Validate',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const handleActivatePayment = (user) => {
|
||||
@@ -165,8 +196,8 @@ const AdminApprovals = () => {
|
||||
const getStatusBadge = (status) => {
|
||||
const config = {
|
||||
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' },
|
||||
pending_approval: { label: 'Pending', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
|
||||
pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_validated: { label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
|
||||
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' }
|
||||
};
|
||||
|
||||
@@ -206,10 +237,10 @@ const AdminApprovals = () => {
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Approval Queue
|
||||
Validation Queue
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review and approve pending membership applications.
|
||||
Review and validate pending membership applications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -229,15 +260,15 @@ const AdminApprovals = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Approval</p>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validation</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'pending_approval').length}
|
||||
{pendingUsers.filter(u => u.status === 'pending_validation').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Approved</p>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Validated</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'pre_approved').length}
|
||||
{pendingUsers.filter(u => u.status === 'pre_validated').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -269,8 +300,8 @@ const AdminApprovals = () => {
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="pending_email">Awaiting Email</SelectItem>
|
||||
<SelectItem value="pending_approval">Pending Approval</SelectItem>
|
||||
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
|
||||
<SelectItem value="pending_validation">Pending Validation</SelectItem>
|
||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -330,12 +361,12 @@ const AdminApprovals = () => {
|
||||
<div className="flex gap-2">
|
||||
{user.status === 'pending_email' ? (
|
||||
<Button
|
||||
onClick={() => handleBypassAndApprove(user.id)}
|
||||
onClick={() => handleBypassAndValidateRequest(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
>
|
||||
{actionLoading === user.id ? 'Approving...' : 'Bypass & Approve'}
|
||||
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
|
||||
</Button>
|
||||
) : user.status === 'payment_pending' ? (
|
||||
<Button
|
||||
@@ -348,12 +379,12 @@ const AdminApprovals = () => {
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleApprove(user.id)}
|
||||
onClick={() => handleValidateRequest(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
|
||||
>
|
||||
{actionLoading === user.id ? 'Validating...' : 'Approve'}
|
||||
{actionLoading === user.id ? 'Validating...' : 'Validate'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -445,7 +476,7 @@ const AdminApprovals = () => {
|
||||
<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 Approvals
|
||||
No Pending Validations
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
@@ -462,8 +493,17 @@ const AdminApprovals = () => {
|
||||
user={selectedUserForPayment}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
/>
|
||||
|
||||
{/* Validation Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={confirmDialogOpen}
|
||||
onOpenChange={setConfirmDialogOpen}
|
||||
onConfirm={confirmAction}
|
||||
loading={actionLoading !== null}
|
||||
{...getActionMessage()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminApprovals;
|
||||
export default AdminValidations;
|
||||
Reference in New Issue
Block a user