Files
gridpilot.gg/apps/website/components/admin/AdminUsersPage.tsx
2026-01-01 15:17:09 +01:00

360 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect } from 'react';
import { apiClient } from '@/lib/apiClient';
import Card from '@/components/ui/Card';
import StatusBadge from '@/components/ui/StatusBadge';
import { AdminViewModelService } from '@/lib/services/AdminViewModelService';
import { AdminUserViewModel, UserListViewModel } from '@/lib/view-models/AdminUserViewModel';
import {
Search,
Filter,
RefreshCw,
Users,
Shield,
Trash2,
AlertTriangle
} from 'lucide-react';
export function AdminUsersPage() {
const [userList, setUserList] = useState<UserListViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [deletingUser, setDeletingUser] = useState<string | null>(null);
useEffect(() => {
const timeout = setTimeout(() => {
loadUsers();
}, 300);
return () => clearTimeout(timeout);
}, [search, roleFilter, statusFilter]);
const loadUsers = async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.admin.listUsers({
search: search || undefined,
role: roleFilter || undefined,
status: statusFilter || undefined,
page: 1,
limit: 50,
});
// Map DTO to View Model
const viewModel = AdminViewModelService.mapUserList(response);
setUserList(viewModel);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load users';
if (message.includes('403') || message.includes('401')) {
setError('Access denied - You must be logged in as an Owner or Admin');
} else {
setError(message);
}
} finally {
setLoading(false);
}
};
const handleUpdateStatus = async (userId: string, newStatus: string) => {
try {
await apiClient.admin.updateUserStatus(userId, newStatus);
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update status');
}
};
const toStatusBadgeProps = (
status: string,
): { status: 'success' | 'warning' | 'error' | 'neutral'; label: string } => {
switch (status) {
case 'active':
return { status: 'success', label: 'Active' };
case 'suspended':
return { status: 'warning', label: 'Suspended' };
case 'deleted':
return { status: 'error', label: 'Deleted' };
default:
return { status: 'neutral', label: status };
}
};
const handleDeleteUser = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
return;
}
try {
setDeletingUser(userId);
await apiClient.admin.deleteUser(userId);
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
} finally {
setDeletingUser(null);
}
};
const clearFilters = () => {
setSearch('');
setRoleFilter('');
setStatusFilter('');
};
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">User Management</h1>
<p className="text-gray-400 mt-1">Manage and monitor all system users</p>
</div>
<button
onClick={loadUsers}
disabled={loading}
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:bg-iron-gray/80 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Error Banner */}
{error && (
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="font-medium">Error</div>
<div className="text-sm opacity-90">{error}</div>
</div>
<button
onClick={() => setError(null)}
className="text-racing-red hover:opacity-70"
>
×
</button>
</div>
)}
{/* Filters Card */}
<Card>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">Filters</span>
</div>
{(search || roleFilter || statusFilter) && (
<button
onClick={clearFilters}
className="text-xs text-primary-blue hover:text-blue-400"
>
Clear all
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue transition-colors"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors"
>
<option value="">All Roles</option>
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="deleted">Deleted</option>
</select>
</div>
</div>
</Card>
{/* Users Table */}
<Card>
{loading ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-blue"></div>
<div className="text-gray-400">Loading users...</div>
</div>
) : !userList || !userList.hasUsers ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3">
<Users className="w-12 h-12 text-gray-600" />
<div className="text-gray-400">No users found</div>
<button
onClick={clearFilters}
className="text-primary-blue hover:text-blue-400 text-sm"
>
Clear filters
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">User</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Email</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Roles</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Status</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Last Login</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Actions</th>
</tr>
</thead>
<tbody>
{userList.users.map((user: AdminUserViewModel, index: number) => (
<tr
key={user.id}
className={`border-b border-charcoal-outline/50 hover:bg-iron-gray/30 transition-colors ${index % 2 === 0 ? 'bg-transparent' : 'bg-iron-gray/10'}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary-blue/20 flex items-center justify-center">
<Shield className="w-4 h-4 text-primary-blue" />
</div>
<div>
<div className="font-medium text-white">{user.displayName}</div>
<div className="text-xs text-gray-500">ID: {user.id}</div>
{user.primaryDriverId && (
<div className="text-xs text-gray-500">Driver: {user.primaryDriverId}</div>
)}
</div>
</div>
</td>
<td className="py-3 px-4">
<div className="text-sm text-gray-300">{user.email}</div>
</td>
<td className="py-3 px-4">
<div className="flex flex-wrap gap-1">
{user.roleBadges.map((badge: string, idx: number) => (
<span
key={idx}
className={`px-2 py-1 text-xs rounded-full font-medium ${
user.roles[idx] === 'owner'
? 'bg-purple-500/20 text-purple-300 border border-purple-500/30'
: user.roles[idx] === 'admin'
? 'bg-blue-500/20 text-blue-300 border border-blue-500/30'
: 'bg-gray-500/20 text-gray-300 border border-gray-500/30'
}`}
>
{badge}
</span>
))}
</div>
</td>
<td className="py-3 px-4">
{(() => {
const badge = toStatusBadgeProps(user.status);
return <StatusBadge status={badge.status} label={badge.label} />;
})()}
</td>
<td className="py-3 px-4">
<div className="text-sm text-gray-400">
{user.lastLoginFormatted}
</div>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
{user.canSuspend && (
<button
onClick={() => handleUpdateStatus(user.id, 'suspended')}
className="px-3 py-1 text-xs rounded bg-yellow-500/20 text-yellow-300 hover:bg-yellow-500/30 transition-colors"
>
Suspend
</button>
)}
{user.canActivate && (
<button
onClick={() => handleUpdateStatus(user.id, 'active')}
className="px-3 py-1 text-xs rounded bg-performance-green/20 text-performance-green hover:bg-performance-green/30 transition-colors"
>
Activate
</button>
)}
{user.canDelete && (
<button
onClick={() => handleDeleteUser(user.id)}
disabled={deletingUser === user.id}
className="px-3 py-1 text-xs rounded bg-racing-red/20 text-racing-red hover:bg-racing-red/30 transition-colors flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
{deletingUser === user.id ? 'Deleting...' : 'Delete'}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{/* Stats Summary */}
{userList && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Total Users</div>
<div className="text-2xl font-bold text-white">{userList.total}</div>
</div>
<Users className="w-6 h-6 text-blue-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Active</div>
<div className="text-2xl font-bold text-white">
{userList.users.filter(u => u.status === 'active').length}
</div>
</div>
<div className="w-6 h-6 text-green-400"></div>
</div>
</Card>
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Admins</div>
<div className="text-2xl font-bold text-white">
{userList.users.filter(u => u.isSystemAdmin).length}
</div>
</div>
<Shield className="w-6 h-6 text-purple-400" />
</div>
</Card>
</div>
)}
</div>
);
}