Add admin moderation and analytics dashboards

Moderation Dashboard:
- API with admin-only access for content moderation
- View/filter reports by status (open, resolved, ignored)
- View/resolve contract disputes
- Manage flagged users (warn, ban, unban)
- Resolution notes for audit trail
- Stats for open reports, disputes, resolved today

Analytics Dashboard:
- Comprehensive platform metrics API
- User stats: total, new, active, creators
- Opportunity and application metrics
- Contract tracking and completion rates
- Revenue tracking by period
- Daily signup trend visualization
- Top performing opportunities ranking
- Period selector (7, 30, 90, 365 days)

Both dashboards have proper admin authorization checks.
This commit is contained in:
Claude 2026-01-26 22:39:47 +00:00
parent ebf62ec80e
commit 88e364f4c5
No known key found for this signature in database
5 changed files with 1406 additions and 0 deletions

187
api/admin/analytics.ts Normal file
View file

@ -0,0 +1,187 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
// Check if user is admin
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", userData.user.id)
.single();
if (!profile || profile.role !== "admin") {
return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" } });
}
const url = new URL(req.url);
const period = url.searchParams.get("period") || "30"; // days
try {
if (req.method === "GET") {
const daysAgo = new Date();
daysAgo.setDate(daysAgo.getDate() - parseInt(period));
// Get total users and growth
const { count: totalUsers } = await supabase
.from("profiles")
.select("*", { count: "exact", head: true });
const { count: newUsersThisPeriod } = await supabase
.from("profiles")
.select("*", { count: "exact", head: true })
.gte("created_at", daysAgo.toISOString());
// Get active users (logged in within period)
const { count: activeUsers } = await supabase
.from("profiles")
.select("*", { count: "exact", head: true })
.gte("last_login_at", daysAgo.toISOString());
// Get opportunities stats
const { count: totalOpportunities } = await supabase
.from("aethex_opportunities")
.select("*", { count: "exact", head: true });
const { count: openOpportunities } = await supabase
.from("aethex_opportunities")
.select("*", { count: "exact", head: true })
.eq("status", "open");
const { count: newOpportunities } = await supabase
.from("aethex_opportunities")
.select("*", { count: "exact", head: true })
.gte("created_at", daysAgo.toISOString());
// Get applications stats
const { count: totalApplications } = await supabase
.from("aethex_applications")
.select("*", { count: "exact", head: true });
const { count: newApplications } = await supabase
.from("aethex_applications")
.select("*", { count: "exact", head: true })
.gte("created_at", daysAgo.toISOString());
// Get contracts stats
const { count: totalContracts } = await supabase
.from("nexus_contracts")
.select("*", { count: "exact", head: true });
const { count: activeContracts } = await supabase
.from("nexus_contracts")
.select("*", { count: "exact", head: true })
.eq("status", "active");
// Get community stats
const { count: totalPosts } = await supabase
.from("community_posts")
.select("*", { count: "exact", head: true });
const { count: newPosts } = await supabase
.from("community_posts")
.select("*", { count: "exact", head: true })
.gte("created_at", daysAgo.toISOString());
// Get creator stats
const { count: totalCreators } = await supabase
.from("aethex_creators")
.select("*", { count: "exact", head: true });
// Get daily signups for trend (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const { data: signupTrend } = await supabase
.from("profiles")
.select("created_at")
.gte("created_at", thirtyDaysAgo.toISOString())
.order("created_at");
// Group signups by date
const signupsByDate: Record<string, number> = {};
signupTrend?.forEach((user) => {
const date = new Date(user.created_at).toISOString().split("T")[0];
signupsByDate[date] = (signupsByDate[date] || 0) + 1;
});
const dailySignups = Object.entries(signupsByDate).map(([date, count]) => ({
date,
count
}));
// Revenue data (if available)
const { data: revenueData } = await supabase
.from("nexus_payments")
.select("amount, created_at")
.eq("status", "completed")
.gte("created_at", daysAgo.toISOString());
const totalRevenue = revenueData?.reduce((sum, p) => sum + (p.amount || 0), 0) || 0;
// Top performing opportunities
const { data: topOpportunities } = await supabase
.from("aethex_opportunities")
.select(`
id,
title,
aethex_applications(count)
`)
.eq("status", "open")
.order("created_at", { ascending: false })
.limit(5);
return new Response(JSON.stringify({
users: {
total: totalUsers || 0,
new: newUsersThisPeriod || 0,
active: activeUsers || 0,
creators: totalCreators || 0
},
opportunities: {
total: totalOpportunities || 0,
open: openOpportunities || 0,
new: newOpportunities || 0
},
applications: {
total: totalApplications || 0,
new: newApplications || 0
},
contracts: {
total: totalContracts || 0,
active: activeContracts || 0
},
community: {
posts: totalPosts || 0,
newPosts: newPosts || 0
},
revenue: {
total: totalRevenue,
period: `${period} days`
},
trends: {
dailySignups,
topOpportunities: topOpportunities?.map(o => ({
id: o.id,
title: o.title,
applications: o.aethex_applications?.[0]?.count || 0
})) || []
},
period: parseInt(period)
}), { headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
console.error("Analytics API error:", err);
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

245
api/admin/moderation.ts Normal file
View file

@ -0,0 +1,245 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
}
// Check if user is admin
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", userData.user.id)
.single();
if (!profile || profile.role !== "admin") {
return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" } });
}
const url = new URL(req.url);
try {
// GET - Fetch reports and stats
if (req.method === "GET") {
const status = url.searchParams.get("status") || "open";
const type = url.searchParams.get("type"); // report, dispute, user
// Get content reports
let reportsQuery = supabase
.from("moderation_reports")
.select(`
*,
reporter:profiles!moderation_reports_reporter_id_fkey(id, full_name, email, avatar_url)
`)
.order("created_at", { ascending: false })
.limit(50);
if (status !== "all") {
reportsQuery = reportsQuery.eq("status", status);
}
if (type && type !== "all") {
reportsQuery = reportsQuery.eq("target_type", type);
}
const { data: reports, error: reportsError } = await reportsQuery;
if (reportsError) console.error("Reports error:", reportsError);
// Get disputes
let disputesQuery = supabase
.from("nexus_disputes")
.select(`
*,
reporter:profiles!nexus_disputes_reported_by_fkey(id, full_name, email)
`)
.order("created_at", { ascending: false })
.limit(50);
if (status !== "all") {
disputesQuery = disputesQuery.eq("status", status);
}
const { data: disputes, error: disputesError } = await disputesQuery;
if (disputesError) console.error("Disputes error:", disputesError);
// Get flagged users (users with warnings/bans)
const { data: flaggedUsers } = await supabase
.from("profiles")
.select("id, full_name, email, avatar_url, is_banned, warning_count, created_at")
.or("is_banned.eq.true,warning_count.gt.0")
.order("created_at", { ascending: false })
.limit(50);
// Calculate stats
const { count: openReports } = await supabase
.from("moderation_reports")
.select("*", { count: "exact", head: true })
.eq("status", "open");
const { count: openDisputes } = await supabase
.from("nexus_disputes")
.select("*", { count: "exact", head: true })
.eq("status", "open");
const { count: resolvedToday } = await supabase
.from("moderation_reports")
.select("*", { count: "exact", head: true })
.eq("status", "resolved")
.gte("updated_at", new Date(new Date().setHours(0, 0, 0, 0)).toISOString());
const stats = {
openReports: openReports || 0,
openDisputes: openDisputes || 0,
resolvedToday: resolvedToday || 0,
flaggedUsers: flaggedUsers?.length || 0
};
return new Response(JSON.stringify({
reports: reports || [],
disputes: disputes || [],
flaggedUsers: flaggedUsers || [],
stats
}), { headers: { "Content-Type": "application/json" } });
}
// POST - Take moderation action
if (req.method === "POST") {
const body = await req.json();
// Resolve/ignore report
if (body.action === "update_report") {
const { report_id, status, resolution_notes } = body;
const { data, error } = await supabase
.from("moderation_reports")
.update({
status,
resolution_notes,
resolved_by: userData.user.id,
updated_at: new Date().toISOString()
})
.eq("id", report_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ report: data }), { headers: { "Content-Type": "application/json" } });
}
// Resolve dispute
if (body.action === "update_dispute") {
const { dispute_id, status, resolution_notes } = body;
const { data, error } = await supabase
.from("nexus_disputes")
.update({
status,
resolution_notes,
resolved_by: userData.user.id,
resolved_at: new Date().toISOString()
})
.eq("id", dispute_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ dispute: data }), { headers: { "Content-Type": "application/json" } });
}
// Ban/warn user
if (body.action === "moderate_user") {
const { user_id, action_type, reason } = body;
if (action_type === "ban") {
const { data, error } = await supabase
.from("profiles")
.update({
is_banned: true,
ban_reason: reason,
banned_at: new Date().toISOString(),
banned_by: userData.user.id
})
.eq("id", user_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ user: data, action: "banned" }), { headers: { "Content-Type": "application/json" } });
}
if (action_type === "warn") {
const { data: currentUser } = await supabase
.from("profiles")
.select("warning_count")
.eq("id", user_id)
.single();
const { data, error } = await supabase
.from("profiles")
.update({
warning_count: (currentUser?.warning_count || 0) + 1,
last_warning_at: new Date().toISOString(),
last_warning_reason: reason
})
.eq("id", user_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ user: data, action: "warned" }), { headers: { "Content-Type": "application/json" } });
}
if (action_type === "unban") {
const { data, error } = await supabase
.from("profiles")
.update({
is_banned: false,
ban_reason: null,
unbanned_at: new Date().toISOString(),
unbanned_by: userData.user.id
})
.eq("id", user_id)
.select()
.single();
if (error) throw error;
return new Response(JSON.stringify({ user: data, action: "unbanned" }), { headers: { "Content-Type": "application/json" } });
}
}
// Delete content
if (body.action === "delete_content") {
const { content_type, content_id } = body;
const tableMap: Record<string, string> = {
post: "community_posts",
comment: "community_comments",
project: "projects",
opportunity: "aethex_opportunities"
};
const table = tableMap[content_type];
if (!table) {
return new Response(JSON.stringify({ error: "Invalid content type" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
const { error } = await supabase.from(table).delete().eq("id", content_id);
if (error) throw error;
return new Response(JSON.stringify({ success: true, deleted: content_type }), { headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Invalid action" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
} catch (err: any) {
console.error("Moderation API error:", err);
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
}
};

View file

@ -167,6 +167,8 @@ import CandidateInterviews from "./pages/candidate/CandidateInterviews";
import CandidateOffers from "./pages/candidate/CandidateOffers";
import StaffOKRs from "./pages/staff/StaffOKRs";
import StaffTimeTracking from "./pages/staff/StaffTimeTracking";
import AdminModeration from "./pages/admin/AdminModeration";
import AdminAnalytics from "./pages/admin/AdminAnalytics";
const queryClient = new QueryClient();
@ -248,6 +250,22 @@ const App = () => (
<Route path="/admin" element={<Admin />} />
<Route path="/admin/feed" element={<AdminFeed />} />
<Route path="/admin/docs-sync" element={<DocsSync />} />
<Route
path="/admin/moderation"
element={
<RequireAccess>
<AdminModeration />
</RequireAccess>
}
/>
<Route
path="/admin/analytics"
element={
<RequireAccess>
<AdminAnalytics />
</RequireAccess>
}
/>
<Route path="/arms" element={<Arms />} />
<Route path="/feed" element={<Navigate to="/community/feed" replace />} />
<Route path="/teams" element={<Teams />} />

View file

@ -0,0 +1,362 @@
import { useState, useEffect } from "react";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
BarChart3,
Users,
Briefcase,
FileText,
DollarSign,
TrendingUp,
Activity,
MessageSquare,
Loader2,
ArrowUpRight,
ArrowDownRight,
} from "lucide-react";
import { useAuth } from "@/lib/auth";
import { aethexToast } from "@/components/ui/aethex-toast";
interface Analytics {
users: {
total: number;
new: number;
active: number;
creators: number;
};
opportunities: {
total: number;
open: number;
new: number;
};
applications: {
total: number;
new: number;
};
contracts: {
total: number;
active: number;
};
community: {
posts: number;
newPosts: number;
};
revenue: {
total: number;
period: string;
};
trends: {
dailySignups: Array<{ date: string; count: number }>;
topOpportunities: Array<{ id: string; title: string; applications: number }>;
};
period: number;
}
export default function AdminAnalytics() {
const { session } = useAuth();
const [analytics, setAnalytics] = useState<Analytics | null>(null);
const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState("30");
useEffect(() => {
if (session?.access_token) {
fetchAnalytics();
}
}, [session?.access_token, period]);
const fetchAnalytics = async () => {
try {
const res = await fetch(`/api/admin/analytics?period=${period}`, {
headers: { Authorization: `Bearer ${session?.access_token}` },
});
const data = await res.json();
if (res.ok) {
setAnalytics(data);
} else {
aethexToast.error(data.error || "Failed to load analytics");
}
} catch (err) {
aethexToast.error("Failed to load analytics");
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0
}).format(amount);
};
const maxSignups = analytics?.trends.dailySignups
? Math.max(...analytics.trends.dailySignups.map(d => d.count), 1)
: 1;
if (loading) {
return (
<Layout>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
</Layout>
);
}
return (
<Layout>
<SEO title="Analytics Dashboard" description="Platform analytics and insights" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-cyan-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-blue-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-7xl px-4 py-16">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-cyan-500/20 border border-cyan-500/30">
<BarChart3 className="h-6 w-6 text-cyan-400" />
</div>
<div>
<h1 className="text-4xl font-bold text-cyan-100">Analytics</h1>
<p className="text-cyan-200/70">Platform insights and metrics</p>
</div>
</div>
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="w-40 bg-slate-800 border-slate-700 text-slate-100">
<SelectValue placeholder="Period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
<SelectItem value="365">Last year</SelectItem>
</SelectContent>
</Select>
</div>
{/* Overview Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="bg-cyan-950/30 border-cyan-500/30">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-cyan-200/70">Total Users</p>
<p className="text-3xl font-bold text-cyan-100">{analytics?.users.total.toLocaleString()}</p>
<div className="flex items-center gap-1 mt-1 text-green-400 text-sm">
<ArrowUpRight className="h-4 w-4" />
+{analytics?.users.new} this period
</div>
</div>
<Users className="h-8 w-8 text-cyan-400" />
</div>
</CardContent>
</Card>
<Card className="bg-cyan-950/30 border-cyan-500/30">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-cyan-200/70">Active Users</p>
<p className="text-3xl font-bold text-cyan-100">{analytics?.users.active.toLocaleString()}</p>
<p className="text-sm text-slate-400 mt-1">
{analytics?.users.total ? Math.round((analytics.users.active / analytics.users.total) * 100) : 0}% of total
</p>
</div>
<Activity className="h-8 w-8 text-cyan-400" />
</div>
</CardContent>
</Card>
<Card className="bg-cyan-950/30 border-cyan-500/30">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-cyan-200/70">Opportunities</p>
<p className="text-3xl font-bold text-cyan-100">{analytics?.opportunities.open}</p>
<div className="flex items-center gap-1 mt-1 text-green-400 text-sm">
<ArrowUpRight className="h-4 w-4" />
+{analytics?.opportunities.new} new
</div>
</div>
<Briefcase className="h-8 w-8 text-cyan-400" />
</div>
</CardContent>
</Card>
<Card className="bg-cyan-950/30 border-cyan-500/30">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-cyan-200/70">Revenue</p>
<p className="text-3xl font-bold text-cyan-100">{formatCurrency(analytics?.revenue.total || 0)}</p>
<p className="text-sm text-slate-400 mt-1">Last {period} days</p>
</div>
<DollarSign className="h-8 w-8 text-cyan-400" />
</div>
</CardContent>
</Card>
</div>
{/* Detailed Stats Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{/* Applications */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<FileText className="h-5 w-5 text-cyan-400" />
Applications
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-slate-400">Total</span>
<span className="text-xl font-bold text-cyan-100">{analytics?.applications.total}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">This Period</span>
<span className="text-xl font-bold text-green-400">+{analytics?.applications.new}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Avg per Opportunity</span>
<span className="text-xl font-bold text-cyan-100">
{analytics?.opportunities.total
? (analytics.applications.total / analytics.opportunities.total).toFixed(1)
: 0}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Contracts */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-cyan-400" />
Contracts
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-slate-400">Total</span>
<span className="text-xl font-bold text-cyan-100">{analytics?.contracts.total}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Active</span>
<span className="text-xl font-bold text-green-400">{analytics?.contracts.active}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Completion Rate</span>
<span className="text-xl font-bold text-cyan-100">
{analytics?.contracts.total
? Math.round(((analytics.contracts.total - analytics.contracts.active) / analytics.contracts.total) * 100)
: 0}%
</span>
</div>
</div>
</CardContent>
</Card>
{/* Community */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-cyan-400" />
Community
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-slate-400">Total Posts</span>
<span className="text-xl font-bold text-cyan-100">{analytics?.community.posts}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">New Posts</span>
<span className="text-xl font-bold text-green-400">+{analytics?.community.newPosts}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Creators</span>
<span className="text-xl font-bold text-cyan-100">{analytics?.users.creators}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Charts Row */}
<div className="grid md:grid-cols-2 gap-6">
{/* Signup Trend */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-slate-100">Daily Signups</CardTitle>
<CardDescription className="text-slate-400">User registrations over the last 30 days</CardDescription>
</CardHeader>
<CardContent>
<div className="h-48 flex items-end gap-1">
{analytics?.trends.dailySignups.slice(-30).map((day, i) => (
<div
key={day.date}
className="flex-1 bg-cyan-500/30 hover:bg-cyan-500/50 transition-colors rounded-t"
style={{ height: `${(day.count / maxSignups) * 100}%`, minHeight: "4px" }}
title={`${day.date}: ${day.count} signups`}
/>
))}
</div>
<div className="flex justify-between mt-2 text-xs text-slate-500">
<span>30 days ago</span>
<span>Today</span>
</div>
</CardContent>
</Card>
{/* Top Opportunities */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-slate-100">Top Opportunities</CardTitle>
<CardDescription className="text-slate-400">By number of applications</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{analytics?.trends.topOpportunities.map((opp, i) => (
<div key={opp.id} className="flex items-center gap-4">
<span className="text-lg font-bold text-cyan-400 w-6">#{i + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-slate-200 truncate">{opp.title}</p>
<p className="text-sm text-slate-500">{opp.applications} applications</p>
</div>
</div>
))}
{(!analytics?.trends.topOpportunities || analytics.trends.topOpportunities.length === 0) && (
<p className="text-slate-500 text-center py-4">No opportunities yet</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</Layout>
);
}

View file

@ -0,0 +1,594 @@
import { useState, useEffect } from "react";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
Shield,
AlertTriangle,
Flag,
UserX,
CheckCircle,
XCircle,
Loader2,
Eye,
Ban,
AlertCircle,
} from "lucide-react";
import { useAuth } from "@/lib/auth";
import { aethexToast } from "@/components/ui/aethex-toast";
interface Report {
id: string;
reporter_id: string;
target_type: string;
target_id: string;
reason: string;
details?: string;
status: string;
created_at: string;
reporter?: {
id: string;
full_name: string;
email: string;
avatar_url?: string;
};
}
interface Dispute {
id: string;
contract_id: string;
reason: string;
status: string;
resolution_notes?: string;
created_at: string;
reporter?: {
id: string;
full_name: string;
email: string;
};
}
interface FlaggedUser {
id: string;
full_name: string;
email: string;
avatar_url?: string;
is_banned: boolean;
warning_count: number;
created_at: string;
}
interface Stats {
openReports: number;
openDisputes: number;
resolvedToday: number;
flaggedUsers: number;
}
const getStatusColor = (status: string) => {
switch (status) {
case "open":
return "bg-red-500/20 text-red-300 border-red-500/30";
case "resolved":
return "bg-green-500/20 text-green-300 border-green-500/30";
case "ignored":
return "bg-slate-500/20 text-slate-300 border-slate-500/30";
default:
return "bg-slate-500/20 text-slate-300";
}
};
const getTypeColor = (type: string) => {
switch (type) {
case "post":
return "bg-blue-500/20 text-blue-300";
case "comment":
return "bg-purple-500/20 text-purple-300";
case "user":
return "bg-amber-500/20 text-amber-300";
case "project":
return "bg-cyan-500/20 text-cyan-300";
default:
return "bg-slate-500/20 text-slate-300";
}
};
export default function AdminModeration() {
const { session } = useAuth();
const [reports, setReports] = useState<Report[]>([]);
const [disputes, setDisputes] = useState<Dispute[]>([]);
const [flaggedUsers, setFlaggedUsers] = useState<FlaggedUser[]>([]);
const [stats, setStats] = useState<Stats>({ openReports: 0, openDisputes: 0, resolvedToday: 0, flaggedUsers: 0 });
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("open");
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
const [selectedDispute, setSelectedDispute] = useState<Dispute | null>(null);
const [selectedUser, setSelectedUser] = useState<FlaggedUser | null>(null);
const [resolution, setResolution] = useState("");
useEffect(() => {
if (session?.access_token) {
fetchModeration();
}
}, [session?.access_token, statusFilter]);
const fetchModeration = async () => {
try {
const res = await fetch(`/api/admin/moderation?status=${statusFilter}`, {
headers: { Authorization: `Bearer ${session?.access_token}` },
});
const data = await res.json();
if (res.ok) {
setReports(data.reports || []);
setDisputes(data.disputes || []);
setFlaggedUsers(data.flaggedUsers || []);
setStats(data.stats || { openReports: 0, openDisputes: 0, resolvedToday: 0, flaggedUsers: 0 });
} else {
aethexToast.error(data.error || "Failed to load moderation data");
}
} catch (err) {
aethexToast.error("Failed to load moderation data");
} finally {
setLoading(false);
}
};
const updateReport = async (reportId: string, status: string) => {
try {
const res = await fetch("/api/admin/moderation", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.access_token}`,
},
body: JSON.stringify({
action: "update_report",
report_id: reportId,
status,
resolution_notes: resolution
}),
});
if (res.ok) {
aethexToast.success(`Report ${status}`);
setSelectedReport(null);
setResolution("");
fetchModeration();
}
} catch (err) {
aethexToast.error("Failed to update report");
}
};
const updateDispute = async (disputeId: string, status: string) => {
try {
const res = await fetch("/api/admin/moderation", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.access_token}`,
},
body: JSON.stringify({
action: "update_dispute",
dispute_id: disputeId,
status,
resolution_notes: resolution
}),
});
if (res.ok) {
aethexToast.success(`Dispute ${status}`);
setSelectedDispute(null);
setResolution("");
fetchModeration();
}
} catch (err) {
aethexToast.error("Failed to update dispute");
}
};
const moderateUser = async (userId: string, actionType: string) => {
const reason = prompt(`Enter reason for ${actionType}:`);
if (!reason && actionType !== "unban") return;
try {
const res = await fetch("/api/admin/moderation", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.access_token}`,
},
body: JSON.stringify({
action: "moderate_user",
user_id: userId,
action_type: actionType,
reason
}),
});
if (res.ok) {
aethexToast.success(`User ${actionType}ned successfully`);
setSelectedUser(null);
fetchModeration();
}
} catch (err) {
aethexToast.error(`Failed to ${actionType} user`);
}
};
if (loading) {
return (
<Layout>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-red-400" />
</div>
</Layout>
);
}
return (
<Layout>
<SEO title="Moderation Dashboard" description="Admin content moderation" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-red-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-orange-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-7xl px-4 py-16">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/30">
<Shield className="h-6 w-6 text-red-400" />
</div>
<div>
<h1 className="text-4xl font-bold text-red-100">Moderation</h1>
<p className="text-red-200/70">Content moderation and user management</p>
</div>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40 bg-slate-800 border-slate-700 text-slate-100">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="ignored">Ignored</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="bg-red-950/30 border-red-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-red-200/70">Open Reports</p>
<p className="text-3xl font-bold text-red-100">{stats.openReports}</p>
</div>
<Flag className="h-8 w-8 text-red-400" />
</div>
</CardContent>
</Card>
<Card className="bg-red-950/30 border-red-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-red-200/70">Open Disputes</p>
<p className="text-3xl font-bold text-red-100">{stats.openDisputes}</p>
</div>
<AlertTriangle className="h-8 w-8 text-red-400" />
</div>
</CardContent>
</Card>
<Card className="bg-red-950/30 border-red-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-red-200/70">Resolved Today</p>
<p className="text-3xl font-bold text-red-100">{stats.resolvedToday}</p>
</div>
<CheckCircle className="h-8 w-8 text-red-400" />
</div>
</CardContent>
</Card>
<Card className="bg-red-950/30 border-red-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-red-200/70">Flagged Users</p>
<p className="text-3xl font-bold text-red-100">{stats.flaggedUsers}</p>
</div>
<UserX className="h-8 w-8 text-red-400" />
</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs defaultValue="reports" className="space-y-6">
<TabsList className="bg-slate-800">
<TabsTrigger value="reports">Reports ({reports.length})</TabsTrigger>
<TabsTrigger value="disputes">Disputes ({disputes.length})</TabsTrigger>
<TabsTrigger value="users">Flagged Users ({flaggedUsers.length})</TabsTrigger>
</TabsList>
{/* Reports Tab */}
<TabsContent value="reports" className="space-y-4">
{reports.map((report) => (
<Card key={report.id} className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge className={`border ${getStatusColor(report.status)}`}>
{report.status}
</Badge>
<Badge className={getTypeColor(report.target_type)}>
{report.target_type}
</Badge>
</div>
<p className="text-slate-200 font-medium mb-1">{report.reason}</p>
{report.details && (
<p className="text-sm text-slate-400 mb-2">{report.details}</p>
)}
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>By: {report.reporter?.full_name || report.reporter?.email || "Unknown"}</span>
<span>{new Date(report.created_at).toLocaleDateString()}</span>
</div>
</div>
{report.status === "open" && (
<div className="flex gap-2">
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => setSelectedReport(report)}
>
<CheckCircle className="h-4 w-4 mr-1" />
Resolve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => updateReport(report.id, "ignored")}
>
<XCircle className="h-4 w-4 mr-1" />
Ignore
</Button>
</div>
)}
</div>
</CardContent>
</Card>
))}
{reports.length === 0 && (
<div className="text-center py-12 text-slate-400">No reports found</div>
)}
</TabsContent>
{/* Disputes Tab */}
<TabsContent value="disputes" className="space-y-4">
{disputes.map((dispute) => (
<Card key={dispute.id} className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge className={`border ${getStatusColor(dispute.status)}`}>
{dispute.status}
</Badge>
<Badge className="bg-purple-500/20 text-purple-300">
Contract Dispute
</Badge>
</div>
<p className="text-slate-200 font-medium mb-1">{dispute.reason}</p>
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>Contract: {dispute.contract_id?.slice(0, 8)}...</span>
<span>By: {dispute.reporter?.full_name || dispute.reporter?.email || "Unknown"}</span>
<span>{new Date(dispute.created_at).toLocaleDateString()}</span>
</div>
</div>
{dispute.status === "open" && (
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => setSelectedDispute(dispute)}
>
<Eye className="h-4 w-4 mr-1" />
Review
</Button>
)}
</div>
</CardContent>
</Card>
))}
{disputes.length === 0 && (
<div className="text-center py-12 text-slate-400">No disputes found</div>
)}
</TabsContent>
{/* Flagged Users Tab */}
<TabsContent value="users" className="space-y-4">
{flaggedUsers.map((user) => (
<Card key={user.id} className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center">
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-10 h-10 rounded-full" />
) : (
<span className="text-slate-400">{user.full_name?.[0] || "?"}</span>
)}
</div>
<div>
<p className="text-slate-200 font-medium">{user.full_name || "Unknown"}</p>
<p className="text-sm text-slate-400">{user.email}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{user.is_banned && (
<Badge className="bg-red-500/20 text-red-300 border-red-500/30">
Banned
</Badge>
)}
{user.warning_count > 0 && (
<Badge className="bg-amber-500/20 text-amber-300 border-amber-500/30">
{user.warning_count} Warnings
</Badge>
)}
</div>
<div className="flex gap-2">
{user.is_banned ? (
<Button
size="sm"
variant="outline"
onClick={() => moderateUser(user.id, "unban")}
>
Unban
</Button>
) : (
<>
<Button
size="sm"
variant="outline"
className="border-amber-500/30 text-amber-300"
onClick={() => moderateUser(user.id, "warn")}
>
<AlertCircle className="h-4 w-4 mr-1" />
Warn
</Button>
<Button
size="sm"
className="bg-red-600 hover:bg-red-700"
onClick={() => moderateUser(user.id, "ban")}
>
<Ban className="h-4 w-4 mr-1" />
Ban
</Button>
</>
)}
</div>
</div>
</div>
</CardContent>
</Card>
))}
{flaggedUsers.length === 0 && (
<div className="text-center py-12 text-slate-400">No flagged users</div>
)}
</TabsContent>
</Tabs>
</div>
</div>
</div>
{/* Resolve Report Dialog */}
<Dialog open={!!selectedReport} onOpenChange={() => setSelectedReport(null)}>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-red-100">Resolve Report</DialogTitle>
</DialogHeader>
{selectedReport && (
<div className="space-y-4">
<div className="p-4 bg-slate-700/50 rounded">
<p className="text-sm text-slate-400 mb-1">Reason</p>
<p className="text-slate-200">{selectedReport.reason}</p>
{selectedReport.details && (
<>
<p className="text-sm text-slate-400 mb-1 mt-3">Details</p>
<p className="text-slate-300 text-sm">{selectedReport.details}</p>
</>
)}
</div>
<Textarea
placeholder="Resolution notes (optional)"
value={resolution}
onChange={(e) => setResolution(e.target.value)}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setSelectedReport(null)}>
Cancel
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => updateReport(selectedReport.id, "resolved")}
>
Resolve
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Review Dispute Dialog */}
<Dialog open={!!selectedDispute} onOpenChange={() => setSelectedDispute(null)}>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-red-100">Review Dispute</DialogTitle>
</DialogHeader>
{selectedDispute && (
<div className="space-y-4">
<div className="p-4 bg-slate-700/50 rounded">
<p className="text-sm text-slate-400 mb-1">Contract</p>
<p className="text-slate-200 font-mono text-sm">{selectedDispute.contract_id}</p>
<p className="text-sm text-slate-400 mb-1 mt-3">Dispute Reason</p>
<p className="text-slate-200">{selectedDispute.reason}</p>
</div>
<Textarea
placeholder="Resolution notes"
value={resolution}
onChange={(e) => setResolution(e.target.value)}
className="bg-slate-700 border-slate-600 text-slate-100"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setSelectedDispute(null)}>
Cancel
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => updateDispute(selectedDispute.id, "resolved")}
>
Resolve
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</Layout>
);
}