Security recovery: hold sweep, dashboard updates, and admin fixes.
This commit is contained in:
@@ -2,22 +2,38 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { usersApi, User } from '@/lib/api';
|
||||
import { usersApi, eventsApi, User, Event } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type RegisteredRange = '' | '7d' | '30d' | '90d';
|
||||
|
||||
function registeredAfterFromRange(range: RegisteredRange): string | undefined {
|
||||
if (!range) return undefined;
|
||||
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
|
||||
const date = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [roleFilter, setRoleFilter] = useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [hasBookingsFilter, setHasBookingsFilter] = useState<'' | 'yes' | 'no'>('');
|
||||
const [registeredRange, setRegisteredRange] = useState<RegisteredRange>('');
|
||||
const [eventFilter, setEventFilter] = useState<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
@@ -30,14 +46,45 @@ export default function AdminUsersPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
|
||||
// Debounce the search box like the Emails page does (300ms).
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebouncedSearch(searchQuery), 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
eventsApi.getAll().then((res) => setEvents(res.events)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, [roleFilter]);
|
||||
}, [roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]);
|
||||
|
||||
const hasActiveFilters =
|
||||
roleFilter || statusFilter || hasBookingsFilter || registeredRange || eventFilter || searchQuery;
|
||||
|
||||
const clearFilters = () => {
|
||||
setRoleFilter('');
|
||||
setStatusFilter('');
|
||||
setHasBookingsFilter('');
|
||||
setRegisteredRange('');
|
||||
setEventFilter('');
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const { users } = await usersApi.getAll(roleFilter || undefined);
|
||||
const { users, total } = await usersApi.getAll({
|
||||
role: roleFilter || undefined,
|
||||
accountStatus: statusFilter || undefined,
|
||||
hasBookings: hasBookingsFilter || undefined,
|
||||
registeredAfter: registeredAfterFromRange(registeredRange),
|
||||
eventId: eventFilter || undefined,
|
||||
search: debouncedSearch.trim() || undefined,
|
||||
pageSize: 200,
|
||||
});
|
||||
setUsers(users);
|
||||
setTotal(total);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load users');
|
||||
} finally {
|
||||
@@ -129,16 +176,29 @@ export default function AdminUsersPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1>
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.users.title')} ({total})</h1>
|
||||
</div>
|
||||
|
||||
{/* Desktop Filters */}
|
||||
<Card className="p-4 mb-6 hidden md:block">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Search</label>
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name, email, phone..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
|
||||
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||
@@ -147,25 +207,82 @@ export default function AdminUsersPage() {
|
||||
<option value="user">{t('admin.users.roles.user')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="unclaimed">Unclaimed</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Has Bookings</label>
|
||||
<select value={hasBookingsFilter} onChange={(e) => setHasBookingsFilter(e.target.value as '' | 'yes' | 'no')}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
|
||||
<option value="">All</option>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Registered</label>
|
||||
<select value={registeredRange} onChange={(e) => setRegisteredRange(e.target.value as RegisteredRange)}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
|
||||
<option value="">All Time</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="90d">Last 90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event</label>
|
||||
<select value={eventFilter} onChange={(e) => setEventFilter(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
|
||||
<option value="">All Events</option>
|
||||
{events.map((event) => (
|
||||
<option key={event.id} value={event.id}>{event.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||
<span>Showing {users.length} of {total}</span>
|
||||
<button onClick={clearFilters} className="text-primary-yellow hover:underline">Clear</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Mobile Toolbar */}
|
||||
<div className="md:hidden mb-4 flex items-center gap-2">
|
||||
<button onClick={() => setMobileFilterOpen(true)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
||||
roleFilter ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
|
||||
)}>
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
{roleFilter ? t(`admin.users.roles.${roleFilter}`) : 'Filter by Role'}
|
||||
</button>
|
||||
{roleFilter && (
|
||||
<button onClick={() => setRoleFilter('')} className="text-xs text-primary-yellow min-h-[44px] flex items-center">
|
||||
Clear
|
||||
<div className="md:hidden mb-4 space-y-2">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name, email, phone..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setMobileFilterOpen(true)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
||||
hasActiveFilters ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
|
||||
)}>
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
Filters
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 ml-auto">{users.length} users</span>
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="text-xs text-primary-yellow min-h-[44px] flex items-center">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 ml-auto">{total} users</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Table */}
|
||||
@@ -176,6 +293,7 @@ export default function AdminUsersPage() {
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">RUC</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
@@ -183,7 +301,7 @@ export default function AdminUsersPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{users.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No users found</td></tr>
|
||||
<tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">No users found</td></tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
@@ -199,6 +317,7 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{user.rucNumber || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
|
||||
@@ -247,6 +366,7 @@ export default function AdminUsersPage() {
|
||||
<p className="font-medium text-sm truncate">{user.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
||||
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
|
||||
{user.rucNumber && <p className="text-[10px] text-gray-400">RUC: {user.rucNumber}</p>}
|
||||
</div>
|
||||
{getRoleBadge(user.role)}
|
||||
</div>
|
||||
@@ -269,26 +389,67 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
|
||||
{/* Mobile Filter BottomSheet */}
|
||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filter by Role">
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ value: '', label: 'All Roles' },
|
||||
{ value: 'admin', label: t('admin.users.roles.admin') },
|
||||
{ value: 'organizer', label: t('admin.users.roles.organizer') },
|
||||
{ value: 'staff', label: t('admin.users.roles.staff') },
|
||||
{ value: 'marketing', label: t('admin.users.roles.marketing') },
|
||||
{ value: 'user', label: t('admin.users.roles.user') },
|
||||
].map((opt) => (
|
||||
<button key={opt.value}
|
||||
onClick={() => { setRoleFilter(opt.value); setMobileFilterOpen(false); }}
|
||||
className={clsx(
|
||||
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
||||
roleFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
||||
)}>
|
||||
{opt.label}
|
||||
{roleFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
||||
</button>
|
||||
))}
|
||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.users.role')}</label>
|
||||
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||
<option value="marketing">{t('admin.users.roles.marketing')}</option>
|
||||
<option value="user">{t('admin.users.roles.user')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="unclaimed">Unclaimed</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Has Bookings</label>
|
||||
<select value={hasBookingsFilter} onChange={(e) => setHasBookingsFilter(e.target.value as '' | 'yes' | 'no')}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||
<option value="">All</option>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Registered</label>
|
||||
<select value={registeredRange} onChange={(e) => setRegisteredRange(e.target.value as RegisteredRange)}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||
<option value="">All Time</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="90d">Last 90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<select value={eventFilter} onChange={(e) => setEventFilter(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||
<option value="">All Events</option>
|
||||
{events.map((event) => (
|
||||
<option key={event.id} value={event.id}>{event.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="outline" onClick={() => { clearFilters(); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">
|
||||
Clear All
|
||||
</Button>
|
||||
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user