Files
thrilltrack-explorer/src/components/moderation/ProfileManager.tsx
2025-09-28 18:26:02 +00:00

437 lines
16 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Search, Ban, Shield, UserCheck, UserX, AlertTriangle } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole, UserRole } from '@/hooks/useUserRole';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useToast } from '@/hooks/use-toast';
interface UserProfile {
id: string;
user_id: string;
username: string;
display_name?: string;
avatar_url?: string;
banned: boolean;
created_at: string;
roles: UserRole[];
}
export function ProfileManager() {
const { user } = useAuth();
const { permissions, loading: roleLoading } = useUserRole();
const { toast } = useToast();
const [profiles, setProfiles] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'banned'>('all');
const [roleFilter, setRoleFilter] = useState<'all' | UserRole>('all');
const [actionLoading, setActionLoading] = useState<string | null>(null);
useEffect(() => {
if (!roleLoading && permissions?.can_view_all_profiles) {
fetchProfiles();
}
}, [roleLoading, permissions]);
const fetchProfiles = async () => {
try {
setLoading(true);
// Fetch profiles with user roles
const { data: profilesData, error: profilesError } = await supabase
.from('profiles')
.select('*')
.order('created_at', { ascending: false });
if (profilesError) throw profilesError;
// Fetch roles for each user
const profilesWithRoles = await Promise.all(
profilesData.map(async (profile) => {
const { data: rolesData } = await supabase
.from('user_roles')
.select('role')
.eq('user_id', profile.user_id);
return {
...profile,
roles: rolesData?.map(r => r.role as UserRole) || []
};
})
);
setProfiles(profilesWithRoles);
} catch (error) {
console.error('Error fetching profiles:', error);
toast({
title: "Error",
description: "Failed to fetch user profiles.",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleBanUser = async (targetUserId: string, ban: boolean) => {
if (!user || !permissions) return;
setActionLoading(targetUserId);
try {
// Update banned status
const { error: updateError } = await supabase
.from('profiles')
.update({ banned: ban })
.eq('user_id', targetUserId);
if (updateError) throw updateError;
// Log the action
const { error: logError } = await supabase
.rpc('log_admin_action', {
_admin_user_id: user.id,
_target_user_id: targetUserId,
_action: ban ? 'ban_user' : 'unban_user',
_details: { banned: ban }
});
if (logError) console.error('Error logging action:', logError);
toast({
title: "Success",
description: `User ${ban ? 'banned' : 'unbanned'} successfully.`,
});
// Refresh profiles
fetchProfiles();
} catch (error) {
console.error('Error updating user ban status:', error);
toast({
title: "Error",
description: `Failed to ${ban ? 'ban' : 'unban'} user.`,
variant: "destructive",
});
} finally {
setActionLoading(null);
}
};
const handleRoleChange = async (targetUserId: string, newRole: UserRole | 'remove', currentRoles: UserRole[]) => {
if (!user || !permissions) return;
setActionLoading(targetUserId);
try {
if (newRole === 'remove') {
// Remove all roles except superuser (which can't be removed via UI)
const rolesToRemove = currentRoles.filter(role => role !== 'superuser');
for (const role of rolesToRemove) {
const { error } = await supabase
.from('user_roles')
.delete()
.eq('user_id', targetUserId)
.eq('role', role);
if (error) throw error;
}
} else {
// Check permissions before allowing role assignment
if (newRole === 'admin' && !permissions.can_manage_admin_roles) {
toast({
title: "Access Denied",
description: "You don't have permission to assign admin roles.",
variant: "destructive",
});
return;
}
if (newRole === 'superuser') {
toast({
title: "Access Denied",
description: "Superuser roles can only be assigned directly in the database.",
variant: "destructive",
});
return;
}
// Add new role
const { error } = await supabase
.from('user_roles')
.upsert({
user_id: targetUserId,
role: newRole,
granted_by: user.id
});
if (error) throw error;
}
// Log the action
const { error: logError } = await supabase
.rpc('log_admin_action', {
_admin_user_id: user.id,
_target_user_id: targetUserId,
_action: newRole === 'remove' ? 'remove_roles' : 'assign_role',
_details: { role: newRole, previous_roles: currentRoles }
});
if (logError) console.error('Error logging action:', logError);
toast({
title: "Success",
description: `User role updated successfully.`,
});
// Refresh profiles
fetchProfiles();
} catch (error) {
console.error('Error updating user role:', error);
toast({
title: "Error",
description: "Failed to update user role.",
variant: "destructive",
});
} finally {
setActionLoading(null);
}
};
const canManageUser = (targetProfile: UserProfile) => {
if (!permissions) return false;
// Superuser can manage anyone except other superusers
if (permissions.role_level === 'superuser') {
return !targetProfile.roles.includes('superuser');
}
// Admin can manage moderators and users, but not admins or superusers
if (permissions.role_level === 'admin') {
return !targetProfile.roles.some(role => ['admin', 'superuser'].includes(role));
}
// Moderator can only ban users with no roles or user role
if (permissions.role_level === 'moderator') {
return targetProfile.roles.length === 0 ||
(targetProfile.roles.length === 1 && targetProfile.roles[0] === 'user');
}
return false;
};
const filteredProfiles = profiles.filter(profile => {
const matchesSearch = profile.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
profile.display_name?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' ||
(statusFilter === 'banned' && profile.banned) ||
(statusFilter === 'active' && !profile.banned);
const matchesRole = roleFilter === 'all' ||
profile.roles.includes(roleFilter as UserRole) ||
(roleFilter === 'user' && profile.roles.length === 0);
return matchesSearch && matchesStatus && matchesRole;
});
if (roleLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading permissions...</p>
</div>
</div>
);
}
if (!permissions?.can_view_all_profiles) {
return (
<div className="text-center py-8">
<Shield className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Access Denied</h3>
<p className="text-muted-foreground">
You don't have permission to manage user profiles.
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search users..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select value={statusFilter} onValueChange={(value: 'all' | 'active' | 'banned') => setStatusFilter(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Users</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="banned">Banned</SelectItem>
</SelectContent>
</Select>
<Select value={roleFilter} onValueChange={(value: 'all' | UserRole) => setRoleFilter(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="superuser">Superuser</SelectItem>
</SelectContent>
</Select>
</div>
{/* Users List */}
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : (
<div className="grid gap-4">
{filteredProfiles.map((profile) => (
<Card key={profile.id}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Avatar className="w-12 h-12">
<AvatarImage src={profile.avatar_url} alt={profile.username} />
<AvatarFallback>
{profile.display_name?.[0] || profile.username[0]}
</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium">{profile.display_name || profile.username}</h3>
{profile.banned && (
<Badge variant="destructive" className="text-xs">
<Ban className="w-3 h-3 mr-1" />
Banned
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">@{profile.username}</p>
<div className="flex gap-2 mt-1">
{profile.roles.length > 0 ? (
profile.roles.map((role) => (
<Badge key={role} variant="secondary" className="text-xs">
{role}
</Badge>
))
) : (
<Badge variant="outline" className="text-xs">User</Badge>
)}
</div>
</div>
</div>
{canManageUser(profile) && (
<div className="flex items-center gap-2">
{/* Ban/Unban Button */}
{permissions.can_ban_any_user && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant={profile.banned ? "outline" : "destructive"}
size="sm"
disabled={actionLoading === profile.user_id}
>
{profile.banned ? (
<>
<UserCheck className="w-4 h-4 mr-2" />
Unban
</>
) : (
<>
<UserX className="w-4 h-4 mr-2" />
Ban
</>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{profile.banned ? 'Unban' : 'Ban'} User
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to {profile.banned ? 'unban' : 'ban'} {profile.username}?
{!profile.banned && ' This will prevent them from accessing the application.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleBanUser(profile.user_id, !profile.banned)}
className={profile.banned ? "" : "bg-destructive hover:bg-destructive/90"}
>
{profile.banned ? 'Unban' : 'Ban'} User
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Role Management */}
{(permissions.can_manage_moderator_roles || permissions.can_manage_admin_roles) && (
<Select
onValueChange={(value) => handleRoleChange(profile.user_id, value as UserRole | 'remove', profile.roles)}
disabled={actionLoading === profile.user_id}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Change Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">Make User</SelectItem>
{permissions.can_manage_moderator_roles && (
<SelectItem value="moderator">Make Moderator</SelectItem>
)}
{permissions.can_manage_admin_roles && (
<SelectItem value="admin">Make Admin</SelectItem>
)}
<SelectItem value="remove">Remove Roles</SelectItem>
</SelectContent>
</Select>
)}
</div>
)}
</div>
</CardContent>
</Card>
))}
{filteredProfiles.length === 0 && (
<div className="text-center py-8">
<AlertTriangle className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No Users Found</h3>
<p className="text-muted-foreground">
No users match your current filters.
</p>
</div>
)}
</div>
)}
</div>
);
}