mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:31:13 -05:00
Add security functions and policies
This commit is contained in:
437
src/components/moderation/ProfileManager.tsx
Normal file
437
src/components/moderation/ProfileManager.tsx
Normal file
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -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<UserRole[]>([]);
|
||||
const [permissions, setPermissions] = useState<UserPermissions | null>(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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="queue" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="queue" className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Moderation Queue
|
||||
@@ -109,8 +110,12 @@ export default function Admin() {
|
||||
<Flag className="w-4 h-4" />
|
||||
Reports
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||
<TabsTrigger value="profiles" className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Profile Management
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
User Roles
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -143,12 +148,26 @@ export default function Admin() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="profiles">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Management</CardTitle>
|
||||
<CardDescription>
|
||||
Manage user profiles, ban status, and role assignments
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProfileManager />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Role Management</CardTitle>
|
||||
<CardDescription>
|
||||
Manage moderator and admin privileges for users
|
||||
Advanced role management and user search
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
Reference in New Issue
Block a user