From d97c41f393574fc21d4e1e1eeef84b7252077e8b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 28 Sep 2025 18:26:02 +0000 Subject: [PATCH] Add security functions and policies --- src/components/moderation/ProfileManager.tsx | 437 ++++++++++++++++++ src/hooks/useUserRole.ts | 42 +- src/integrations/supabase/types.ts | 48 ++ src/pages/Admin.tsx | 25 +- ...5_47556b14-e1fb-4a14-b379-1567a5fdc057.sql | 193 ++++++++ 5 files changed, 734 insertions(+), 11 deletions(-) create mode 100644 src/components/moderation/ProfileManager.tsx create mode 100644 supabase/migrations/20250928182415_47556b14-e1fb-4a14-b379-1567a5fdc057.sql diff --git a/src/components/moderation/ProfileManager.tsx b/src/components/moderation/ProfileManager.tsx new file mode 100644 index 00000000..8ab41c4d --- /dev/null +++ b/src/components/moderation/ProfileManager.tsx @@ -0,0 +1,437 @@ +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([]); + 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(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 ( +
+
+
+

Loading permissions...

+
+
+ ); + } + + if (!permissions?.can_view_all_profiles) { + return ( +
+ +

Access Denied

+

+ You don't have permission to manage user profiles. +

+
+ ); + } + + return ( +
+ {/* Filters */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + + + +
+ + {/* Users List */} + {loading ? ( +
+
+
+ ) : ( +
+ {filteredProfiles.map((profile) => ( + + +
+
+ + + + {profile.display_name?.[0] || profile.username[0]} + + + +
+
+

{profile.display_name || profile.username}

+ {profile.banned && ( + + + Banned + + )} +
+

@{profile.username}

+
+ {profile.roles.length > 0 ? ( + profile.roles.map((role) => ( + + {role} + + )) + ) : ( + User + )} +
+
+
+ + {canManageUser(profile) && ( +
+ {/* Ban/Unban Button */} + {permissions.can_ban_any_user && ( + + + + + + + + {profile.banned ? 'Unban' : 'Ban'} User + + + Are you sure you want to {profile.banned ? 'unban' : 'ban'} {profile.username}? + {!profile.banned && ' This will prevent them from accessing the application.'} + + + + Cancel + handleBanUser(profile.user_id, !profile.banned)} + className={profile.banned ? "" : "bg-destructive hover:bg-destructive/90"} + > + {profile.banned ? 'Unban' : 'Ban'} User + + + + + )} + + {/* Role Management */} + {(permissions.can_manage_moderator_roles || permissions.can_manage_admin_roles) && ( + + )} +
+ )} +
+
+
+ ))} + + {filteredProfiles.length === 0 && ( +
+ +

No Users Found

+

+ No users match your current filters. +

+
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/hooks/useUserRole.ts b/src/hooks/useUserRole.ts index 7c2f0f81..3a53ef35 100644 --- a/src/hooks/useUserRole.ts +++ b/src/hooks/useUserRole.ts @@ -2,11 +2,21 @@ import { useState, useEffect } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; -export type UserRole = 'admin' | 'moderator' | 'user'; +export type UserRole = 'admin' | 'moderator' | 'user' | 'superuser'; + +export interface UserPermissions { + can_ban_any_user: boolean; + can_manage_admin_roles: boolean; + can_manage_moderator_roles: boolean; + can_view_all_profiles: boolean; + can_assign_superuser: boolean; + role_level: string; +} export function useUserRole() { const { user } = useAuth(); const [roles, setRoles] = useState([]); + const [permissions, setPermissions] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { @@ -18,20 +28,33 @@ export function useUserRole() { const fetchRoles = async () => { try { - const { data, error } = await supabase + // Fetch user roles + const { data: rolesData, error: rolesError } = await supabase .from('user_roles') .select('role') .eq('user_id', user.id); - if (error) { - console.error('Error fetching user roles:', error); + if (rolesError) { + console.error('Error fetching user roles:', rolesError); setRoles([]); } else { - setRoles(data?.map(r => r.role as UserRole) || []); + setRoles(rolesData?.map(r => r.role as UserRole) || []); + } + + // Fetch user permissions using the new function + const { data: permissionsData, error: permissionsError } = await supabase + .rpc('get_user_management_permissions', { _user_id: user.id }); + + if (permissionsError) { + console.error('Error fetching user permissions:', permissionsError); + setPermissions(null); + } else { + setPermissions(permissionsData as unknown as UserPermissions); } } catch (error) { console.error('Error fetching user roles:', error); setRoles([]); + setPermissions(null); } finally { setLoading(false); } @@ -41,14 +64,17 @@ export function useUserRole() { }, [user]); const hasRole = (role: UserRole) => roles.includes(role); - const isModerator = () => hasRole('admin') || hasRole('moderator'); - const isAdmin = () => hasRole('admin'); + const isModerator = () => hasRole('admin') || hasRole('moderator') || hasRole('superuser'); + const isAdmin = () => hasRole('admin') || hasRole('superuser'); + const isSuperuser = () => hasRole('superuser'); return { roles, + permissions, loading, hasRole, isModerator, - isAdmin + isAdmin, + isSuperuser }; } \ No newline at end of file diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 654a7ddf..5d9ed527 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -14,6 +14,33 @@ export type Database = { } public: { Tables: { + admin_audit_log: { + Row: { + action: string + admin_user_id: string + created_at: string + details: Json | null + id: string + target_user_id: string + } + Insert: { + action: string + admin_user_id: string + created_at?: string + details?: Json | null + id?: string + target_user_id: string + } + Update: { + action?: string + admin_user_id?: string + created_at?: string + details?: Json | null + id?: string + target_user_id?: string + } + Relationships: [] + } companies: { Row: { average_rating: number | null @@ -753,6 +780,14 @@ export type Database = { [_ in never]: never } Functions: { + can_manage_user: { + Args: { _manager_id: string; _target_user_id: string } + Returns: boolean + } + get_user_management_permissions: { + Args: { _user_id: string } + Returns: Json + } has_role: { Args: { _role: Database["public"]["Enums"]["app_role"] @@ -764,6 +799,19 @@ export type Database = { Args: { _user_id: string } Returns: boolean } + is_superuser: { + Args: { _user_id: string } + Returns: boolean + } + log_admin_action: { + Args: { + _action: string + _admin_user_id: string + _details?: Json + _target_user_id: string + } + Returns: undefined + } update_company_ratings: { Args: { target_company_id: string } Returns: undefined diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index d4ba0020..42eb32c4 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -9,6 +9,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ModerationQueue } from '@/components/moderation/ModerationQueue'; import { ReportsQueue } from '@/components/moderation/ReportsQueue'; import { UserRoleManager } from '@/components/moderation/UserRoleManager'; +import { ProfileManager } from '@/components/moderation/ProfileManager'; export default function Admin() { const { user, loading: authLoading } = useAuth(); @@ -100,7 +101,7 @@ export default function Admin() { - + Moderation Queue @@ -109,8 +110,12 @@ export default function Admin() { Reports - + + Profile Management + + + User Roles @@ -143,12 +148,26 @@ export default function Admin() { + + + + Profile Management + + Manage user profiles, ban status, and role assignments + + + + + + + + User Role Management - Manage moderator and admin privileges for users + Advanced role management and user search diff --git a/supabase/migrations/20250928182415_47556b14-e1fb-4a14-b379-1567a5fdc057.sql b/supabase/migrations/20250928182415_47556b14-e1fb-4a14-b379-1567a5fdc057.sql new file mode 100644 index 00000000..24b1dc3c --- /dev/null +++ b/supabase/migrations/20250928182415_47556b14-e1fb-4a14-b379-1567a5fdc057.sql @@ -0,0 +1,193 @@ +-- Create function to check if user is superuser +CREATE OR REPLACE FUNCTION public.is_superuser(_user_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE SECURITY DEFINER +SET search_path TO 'public' +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.user_roles + WHERE user_id = _user_id + AND role = 'superuser' + ) +$$; + +-- Create function to check if user can manage another user +CREATE OR REPLACE FUNCTION public.can_manage_user(_manager_id uuid, _target_user_id uuid) +RETURNS boolean +LANGUAGE plpgsql +STABLE SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + manager_roles app_role[]; + target_roles app_role[]; + has_superuser boolean := false; + has_admin boolean := false; + has_moderator boolean := false; +BEGIN + -- Get manager roles + SELECT ARRAY_AGG(role) INTO manager_roles + FROM public.user_roles + WHERE user_id = _manager_id; + + -- Get target user roles + SELECT ARRAY_AGG(role) INTO target_roles + FROM public.user_roles + WHERE user_id = _target_user_id; + + -- Check manager permissions + IF 'superuser' = ANY(manager_roles) THEN + has_superuser := true; + END IF; + + IF 'admin' = ANY(manager_roles) THEN + has_admin := true; + END IF; + + IF 'moderator' = ANY(manager_roles) THEN + has_moderator := true; + END IF; + + -- Superuser can manage anyone except other superusers + IF has_superuser AND NOT ('superuser' = ANY(target_roles)) THEN + RETURN true; + END IF; + + -- Admin can manage moderators and users, but not admins or superusers + IF has_admin AND NOT ('admin' = ANY(target_roles) OR 'superuser' = ANY(target_roles)) THEN + RETURN true; + END IF; + + -- Moderator can only ban users with no roles or user role + IF has_moderator AND (target_roles IS NULL OR (ARRAY_LENGTH(target_roles, 1) IS NULL) OR (target_roles = ARRAY['user'::app_role])) THEN + RETURN true; + END IF; + + RETURN false; +END; +$$; + +-- Create function to get user management permissions for current user +CREATE OR REPLACE FUNCTION public.get_user_management_permissions(_user_id uuid) +RETURNS jsonb +LANGUAGE plpgsql +STABLE SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + user_roles app_role[]; + permissions jsonb := '{}'; +BEGIN + -- Get user roles + SELECT ARRAY_AGG(role) INTO user_roles + FROM public.user_roles + WHERE user_id = _user_id; + + -- Set permissions based on roles + IF 'superuser' = ANY(user_roles) THEN + permissions := jsonb_build_object( + 'can_ban_any_user', true, + 'can_manage_admin_roles', true, + 'can_manage_moderator_roles', true, + 'can_view_all_profiles', true, + 'can_assign_superuser', false, + 'role_level', 'superuser' + ); + ELSIF 'admin' = ANY(user_roles) THEN + permissions := jsonb_build_object( + 'can_ban_any_user', true, + 'can_manage_admin_roles', false, + 'can_manage_moderator_roles', true, + 'can_view_all_profiles', true, + 'can_assign_superuser', false, + 'role_level', 'admin' + ); + ELSIF 'moderator' = ANY(user_roles) THEN + permissions := jsonb_build_object( + 'can_ban_any_user', false, + 'can_manage_admin_roles', false, + 'can_manage_moderator_roles', false, + 'can_view_all_profiles', true, + 'can_assign_superuser', false, + 'role_level', 'moderator' + ); + ELSE + permissions := jsonb_build_object( + 'can_ban_any_user', false, + 'can_manage_admin_roles', false, + 'can_manage_moderator_roles', false, + 'can_view_all_profiles', false, + 'can_assign_superuser', false, + 'role_level', 'user' + ); + END IF; + + RETURN permissions; +END; +$$; + +-- Update RLS policies for profiles to allow admin access +DROP POLICY IF EXISTS "Moderators can view all profiles" ON public.profiles; +CREATE POLICY "Admins and moderators can view all profiles" +ON public.profiles +FOR SELECT +USING ( + (privacy_level = 'public') OR + (auth.uid() = user_id) OR + is_moderator(auth.uid()) +); + +-- Allow admins and superusers to update profiles (including ban status) +CREATE POLICY "Admins can update any profile" +ON public.profiles +FOR UPDATE +USING ( + (auth.uid() = user_id) OR + has_role(auth.uid(), 'admin') OR + is_superuser(auth.uid()) +); + +-- Create audit log table for tracking admin actions +CREATE TABLE IF NOT EXISTS public.admin_audit_log ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + admin_user_id uuid NOT NULL, + target_user_id uuid NOT NULL, + action text NOT NULL, + details jsonb, + created_at timestamp with time zone NOT NULL DEFAULT now() +); + +-- Enable RLS on audit log +ALTER TABLE public.admin_audit_log ENABLE ROW LEVEL SECURITY; + +-- Only admins and superusers can view audit log +CREATE POLICY "Admins can view audit log" +ON public.admin_audit_log +FOR SELECT +USING (is_moderator(auth.uid())); + +-- Only admins and superusers can insert into audit log +CREATE POLICY "Admins can insert audit log" +ON public.admin_audit_log +FOR INSERT +WITH CHECK (is_moderator(auth.uid())); + +-- Create function to log admin actions +CREATE OR REPLACE FUNCTION public.log_admin_action( + _admin_user_id uuid, + _target_user_id uuid, + _action text, + _details jsonb DEFAULT NULL +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +BEGIN + INSERT INTO public.admin_audit_log (admin_user_id, target_user_id, action, details) + VALUES (_admin_user_id, _target_user_id, _action, _details); +END; +$$; \ No newline at end of file