mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 03:11:15 -05:00
484 lines
17 KiB
TypeScript
484 lines
17 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Search, Shield, Trash2, Ban, AlertTriangle } from 'lucide-react';
|
|
import { supabase } from '@/lib/supabaseClient';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { useUserRole, UserRole } from '@/hooks/useUserRole';
|
|
import { useSuperuserGuard } from '@/hooks/useSuperuserGuard';
|
|
import { AdminUserDeletionDialog } from '@/components/admin/AdminUserDeletionDialog';
|
|
import { BanUserDialog } from '@/components/admin/BanUserDialog';
|
|
import { Card, CardContent } 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { handleError, handleSuccess, handleNonCriticalError, getErrorMessage } from '@/lib/errorHandler';
|
|
|
|
interface UserProfile {
|
|
id: string;
|
|
user_id: string;
|
|
username: string;
|
|
email?: 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 [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);
|
|
const [deletionTarget, setDeletionTarget] = useState<UserProfile | null>(null);
|
|
const superuserGuard = useSuperuserGuard();
|
|
|
|
useEffect(() => {
|
|
if (!roleLoading && permissions?.can_view_all_profiles) {
|
|
fetchProfiles();
|
|
}
|
|
}, [roleLoading, permissions]);
|
|
|
|
const fetchProfiles = async () => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
// Fetch profiles with emails using secure RPC function
|
|
const { data: profilesData, error: profilesError } = await supabase
|
|
.rpc('get_users_with_emails');
|
|
|
|
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: unknown) {
|
|
handleError(error, {
|
|
action: 'Load User Profiles',
|
|
userId: user?.id
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleBanUser = async (
|
|
targetUserId: string,
|
|
ban: boolean,
|
|
banReason?: string,
|
|
banExpiresAt?: Date | null
|
|
) => {
|
|
if (!user || !permissions) return;
|
|
|
|
setActionLoading(targetUserId);
|
|
try {
|
|
// Prepare update data
|
|
interface ProfileUpdateData {
|
|
banned: boolean;
|
|
ban_reason?: string | null;
|
|
ban_expires_at?: string | null;
|
|
}
|
|
|
|
const updateData: ProfileUpdateData = { banned: ban };
|
|
|
|
if (ban && banReason) {
|
|
updateData.ban_reason = banReason;
|
|
updateData.ban_expires_at = banExpiresAt ? banExpiresAt.toISOString() : null;
|
|
} else if (!ban) {
|
|
// Clear ban data when unbanning
|
|
updateData.ban_reason = null;
|
|
updateData.ban_expires_at = null;
|
|
}
|
|
|
|
// Update banned status
|
|
const { error: updateError } = await supabase
|
|
.from('profiles')
|
|
.update(updateData)
|
|
.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,
|
|
ban_reason: banReason,
|
|
ban_expires_at: banExpiresAt?.toISOString()
|
|
}
|
|
});
|
|
|
|
if (logError) {
|
|
handleNonCriticalError(logError, {
|
|
action: 'Log admin action (ban/unban)',
|
|
userId: user?.id,
|
|
metadata: { targetUserId, ban, banReason }
|
|
});
|
|
}
|
|
|
|
handleSuccess(
|
|
'Success',
|
|
ban
|
|
? 'User banned successfully. They have been signed out and cannot access the application.'
|
|
: 'User unbanned successfully. They can now access the application normally.'
|
|
);
|
|
|
|
// Refresh profiles
|
|
fetchProfiles();
|
|
} catch (error: unknown) {
|
|
handleError(error, {
|
|
action: `${ban ? 'Ban' : 'Unban'} User`,
|
|
userId: user?.id,
|
|
metadata: { targetUserId, ban, banReason, banExpiresAt }
|
|
});
|
|
} 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) {
|
|
handleError(new Error('Insufficient permissions'), {
|
|
action: 'Assign Admin Role',
|
|
userId: user?.id,
|
|
metadata: { targetUserId, attemptedRole: newRole }
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (newRole === 'superuser') {
|
|
handleError(new Error('Cannot assign superuser via UI'), {
|
|
action: 'Assign Superuser Role',
|
|
userId: user?.id,
|
|
metadata: { targetUserId }
|
|
});
|
|
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) {
|
|
handleNonCriticalError(logError, {
|
|
action: 'Log admin action (role change)',
|
|
userId: user?.id,
|
|
metadata: { targetUserId, newRole, previousRoles: currentRoles }
|
|
});
|
|
}
|
|
|
|
handleSuccess('Success', 'User role updated successfully.');
|
|
|
|
// Refresh profiles
|
|
fetchProfiles();
|
|
} catch (error: unknown) {
|
|
handleError(error, {
|
|
action: 'Update User Role',
|
|
userId: user?.id,
|
|
metadata: { targetUserId, newRole, previousRoles: currentRoles }
|
|
});
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
// Check if current superuser can delete a specific user
|
|
const canDeleteUser = (targetProfile: UserProfile) => {
|
|
if (!superuserGuard.isSuperuser) return false;
|
|
if (!superuserGuard.canPerformAction) return false;
|
|
|
|
// Cannot delete other superusers
|
|
if (targetProfile.roles.includes('superuser')) return false;
|
|
|
|
// Cannot delete self
|
|
if (targetProfile.user_id === user?.id) return false;
|
|
|
|
return true;
|
|
};
|
|
|
|
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) || canDeleteUser(profile)) && (
|
|
<div className="flex items-center gap-2">
|
|
{/* Ban/Unban Button */}
|
|
{canManageUser(profile) && permissions.can_ban_any_user && (
|
|
<BanUserDialog
|
|
profile={profile}
|
|
onBanComplete={fetchProfiles}
|
|
onBanUser={handleBanUser}
|
|
disabled={actionLoading === profile.user_id}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete User Button - Superusers Only */}
|
|
{canDeleteUser(profile) && (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setDeletionTarget(profile)}
|
|
disabled={actionLoading === profile.user_id}
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Delete User
|
|
</Button>
|
|
)}
|
|
|
|
{/* Role Management */}
|
|
{canManageUser(profile) && (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>
|
|
)}
|
|
|
|
{/* User Deletion Dialog */}
|
|
{deletionTarget && (
|
|
<AdminUserDeletionDialog
|
|
open={!!deletionTarget}
|
|
onOpenChange={(open) => !open && setDeletionTarget(null)}
|
|
targetUser={{
|
|
userId: deletionTarget.user_id,
|
|
username: deletionTarget.username,
|
|
email: deletionTarget.email || 'Email not found',
|
|
displayName: deletionTarget.display_name || undefined,
|
|
roles: deletionTarget.roles
|
|
}}
|
|
onDeletionComplete={() => {
|
|
setDeletionTarget(null);
|
|
fetchProfiles();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
} |