diff --git a/api/env.example b/api/env.example index 9aba299..7a9a911 100644 --- a/api/env.example +++ b/api/env.example @@ -33,9 +33,12 @@ S3_SECRET_KEY= LOCAL_STORE_DIR=./data/object_store # --- CORS --- -# Comma-separated allowed origins for dashboard/API calls, e.g. https://asyncanticheat.com,https://dashboard.asyncanticheat.com -# Leave empty for permissive CORS (dev only). +# Comma-separated allowed origins for cross-origin requests. +# Example: https://asyncanticheat.com,https://www.asyncanticheat.com +# If empty and CORS_PERMISSIVE_DEV is not set, uses restrictive same-origin CORS. CORS_ALLOW_ORIGINS= +# Set to true to enable permissive CORS (dev only, DO NOT use in production!) +CORS_PERMISSIVE_DEV=false # --- Logging --- RUST_LOG=info,async_anticheat_api=debug diff --git a/web/app/(dashboard)/dashboard/findings/page.tsx b/web/app/(dashboard)/dashboard/findings/page.tsx index 6f99545..c750dfd 100644 --- a/web/app/(dashboard)/dashboard/findings/page.tsx +++ b/web/app/(dashboard)/dashboard/findings/page.tsx @@ -10,6 +10,7 @@ import { RiArrowLeftLine, RiFlagLine, RiFlagFill, + RiDeleteBinLine, } from "@remixicon/react"; import { cn, @@ -21,6 +22,8 @@ import { import { type Finding } from "@/lib/api"; import { useSelectedServer } from "@/lib/server-context"; import { ReportFalsePositiveDialog } from "@/components/dashboard/report-false-positive-dialog"; +import { ViewFalsePositiveReportModal } from "@/components/dashboard/view-false-positive-report-modal"; +import { DeleteFindingDialog } from "@/components/dashboard/delete-finding-dialog"; import { useFindings, useFalsePositiveReports } from "@/lib/hooks/use-dashboard-data"; const severityColors = { @@ -121,12 +124,16 @@ function PlayerHistoryPanel({ findings, onClose, onReportFalsePositive, + onViewReport, + onDeleteFinding, reportedFindingIds, }: { playerName: string; findings: Finding[]; onClose: () => void; onReportFalsePositive: (finding: Finding) => void; + onViewReport: (finding: Finding) => void; + onDeleteFinding: (finding: Finding) => void; reportedFindingIds: Set; }) { // Normalize player name comparison to handle "Unknown" entries @@ -331,12 +338,16 @@ function PlayerHistoryPanel({ })()}
{isReported ? ( -
{ + e.stopPropagation(); + onViewReport(finding); + }} + className="p-1 text-amber-400 hover:bg-amber-500/10 rounded transition-colors cursor-pointer" + title="View false positive report" > -
+ ) : ( )} + {time} @@ -370,9 +391,16 @@ function PlayerHistoryPanel({ )} {isReported && ( - + )}
(null); + // View false positive report modal state + const [viewReportFinding, setViewReportFinding] = useState(null); + + // Delete finding dialog state + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedFindingForDelete, setSelectedFindingForDelete] = useState(null); + const handleReportFalsePositive = useCallback((finding: Finding) => { setSelectedFindingForReport(finding); setReportDialogOpen(true); }, []); + const handleViewReport = useCallback((finding: Finding) => { + setViewReportFinding(finding); + }, []); + + const handleReportDeleted = useCallback((findingId: string) => { + removeReport(findingId); + }, [removeReport]); + // Handle successful false positive report submission const handleReportSuccess = useCallback((findingId: string) => { addReport(findingId); }, [addReport]); + const handleDeleteFinding = useCallback((finding: Finding) => { + setSelectedFindingForDelete(finding); + setDeleteDialogOpen(true); + }, []); + + const handleDeleteSuccess = useCallback((findingId: string) => { + removeFinding(findingId); + removeReport(findingId); // Also remove from reported set if it was reported + }, [removeFinding, removeReport]); + // Check for player query param on mount useEffect(() => { const playerParam = searchParams.get("player"); @@ -656,10 +710,19 @@ export default function FindingsPage() { isReported && "opacity-60" )} > - )} @@ -733,15 +803,16 @@ export default function FindingsPage() { {time} - + {/* Report False Positive Button */} {isReported ? ( -
handleViewReport(finding)} + className="p-2 text-amber-400 flex-shrink-0 hover:bg-amber-500/10 rounded-lg transition-colors cursor-pointer" + title="View false positive report" > -
+ ) : ( )} + {/* Delete Finding Button */} + ); })} @@ -781,6 +860,8 @@ export default function FindingsPage() { setSearch(""); }} onReportFalsePositive={handleReportFalsePositive} + onViewReport={handleViewReport} + onDeleteFinding={handleDeleteFinding} reportedFindingIds={reportedFindingIds} /> @@ -797,6 +878,27 @@ export default function FindingsPage() { onReportSuccess={handleReportSuccess} /> )} + + {/* View False Positive Report Modal */} + {selectedServerId && viewReportFinding && ( + setViewReportFinding(null)} + serverId={selectedServerId} + onReportDeleted={handleReportDeleted} + /> + )} + + {/* Delete Finding Dialog */} + {selectedServerId && ( + + )} ); } diff --git a/web/app/(dashboard)/dashboard/modules/page.tsx b/web/app/(dashboard)/dashboard/modules/page.tsx index 2eb69b1..17a5827 100644 --- a/web/app/(dashboard)/dashboard/modules/page.tsx +++ b/web/app/(dashboard)/dashboard/modules/page.tsx @@ -518,7 +518,7 @@ function ModuleDetailPanel({ {module.base_url} + + + {/* Content */} +
+
+

{message}

+
+

+ The dashboard will retry automatically. Check that the API server is + running and accessible. +

+ + {/* Actions */} +
+ {onRetry && ( + + )} + +
+
+ + + ); +} + export default function DashboardPage() { const selectedServerId = useSelectedServer(); const [mounted, setMounted] = useState(false); @@ -630,9 +698,11 @@ export default function DashboardPage() { const [activePlayers, setActivePlayers] = useState([]); const [playersLoading, setPlayersLoading] = useState(true); const [playersError, setPlayersError] = useState(null); + const [errorDismissed, setErrorDismissed] = useState(false); + const [playersRefetchKey, setPlayersRefetchKey] = useState(0); // Use SWR hooks for stats and connection metrics (cached across navigation) - const { stats, isLoading: statsLoading } = useDashboardStats(selectedServerId); + const { stats, error: statsError, isLoading: statsLoading, mutate: mutateStats } = useDashboardStats(selectedServerId); const { metrics: connectionMetrics } = useConnectionMetrics(selectedServerId); // Track current server ID to prevent stale responses from updating state @@ -719,11 +789,29 @@ export default function DashboardPage() { // Refresh players every 30 seconds const interval = setInterval(fetchPlayers, 30000); return () => clearInterval(interval); - }, [selectedServerId]); + }, [selectedServerId, playersRefetchKey]); // Combined loading state - show skeleton only on first load const loading = (statsLoading && !stats) || (playersLoading && players.length === 0); - const error = playersError; + + // Combine all errors - show the first one that occurred + const activeError = statsError?.message || playersError; + + // Reset dismissed state when error changes + useEffect(() => { + if (activeError) { + setErrorDismissed(false); + } + }, [activeError]); + + // Retry function for the error banner + const handleRetry = () => { + setErrorDismissed(false); + setPlayersError(null); + mutateStats(); + // Trigger players refetch by incrementing the key + setPlayersRefetchKey((k) => k + 1); + }; if (!mounted) return null; @@ -758,8 +846,18 @@ export default function DashboardPage() { } return ( -
- {/* Globe Section - Left on desktop, top on mobile */} + <> + {/* Error Modal */} + {activeError && !errorDismissed && ( + setErrorDismissed(true)} + onRetry={handleRetry} + /> + )} + +
+ {/* Globe Section - Left on desktop, top on mobile */}
- {/* Error state */} - {error && ( -
-
- {error} -
-
- )} {/* Bottom stats bar - hidden on mobile, shown below globe on desktop */}
@@ -1126,6 +1216,7 @@ export default function DashboardPage() { />
-
+ + ); } diff --git a/web/components/dashboard/delete-finding-dialog.tsx b/web/components/dashboard/delete-finding-dialog.tsx new file mode 100644 index 0000000..a37f8c6 --- /dev/null +++ b/web/components/dashboard/delete-finding-dialog.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { RiDeleteBinLine, RiLoader4Line, RiCheckLine, RiAlertLine } from "@remixicon/react"; +import type { Finding } from "@/lib/api"; +import { createClient } from "@/lib/supabase/client"; +import { useInvalidateFindingsCache } from "@/lib/hooks/use-dashboard-data"; + +interface DeleteFindingDialogProps { + finding: Finding | null; + open: boolean; + onOpenChange: (open: boolean) => void; + serverId: string | null; + onDeleteSuccess?: (findingId: string) => void; +} + +export function DeleteFindingDialog({ + finding, + open, + onOpenChange, + serverId, + onDeleteSuccess, +}: DeleteFindingDialogProps) { + const { invalidateAll } = useInvalidateFindingsCache(serverId); + const [deleting, setDeleting] = useState(false); + const [deleted, setDeleted] = useState(false); + const [error, setError] = useState(null); + + // Store timeout IDs so we can clear them when finding changes or component unmounts + const closeTimeoutRef = useRef(null); + const resetTimeoutRef = useRef(null); + + // Reset state and clear pending timeouts when dialog opens or finding changes + useEffect(() => { + if (open) { + // Clear any pending timeouts from previous deletion + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + resetTimeoutRef.current = null; + } + setDeleting(false); + setDeleted(false); + setError(null); + } + }, [open, finding?.id]); + + // Cleanup timeouts on unmount + useEffect(() => { + return () => { + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); + if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current); + }; + }, []); + + const handleDelete = async () => { + if (!finding) return; + + setDeleting(true); + setError(null); + + try { + const supabase = createClient(); + + // First delete any associated false positive reports + const { error: reportDeleteError } = await supabase + .from("false_positive_reports") + .delete() + .eq("finding_id", finding.id); + + if (reportDeleteError) { + throw new Error(reportDeleteError.message); + } + + // Then delete the finding itself + const { error: deleteError } = await supabase + .from("findings") + .delete() + .eq("id", finding.id); + + if (deleteError) { + throw new Error(deleteError.message); + } + + // Notify parent of successful delete + onDeleteSuccess?.(finding.id); + + // Invalidate all finding-related caches to refresh players, stats, etc. + invalidateAll(); + + setDeleted(true); + closeTimeoutRef.current = setTimeout(() => { + onOpenChange(false); + // Reset state after dialog closes + resetTimeoutRef.current = setTimeout(() => { + setDeleted(false); + }, 200); + }, 1000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete finding"); + } finally { + setDeleting(false); + } + }; + + return ( + + + + + + Delete Finding + + + This action cannot be undone. The finding will be permanently removed. + + + + {deleted ? ( +
+
+ +
+

+ Finding Deleted +

+

+ The finding has been permanently removed. +

+
+ ) : ( +
+
+ {/* Warning Banner */} +
+ +
+ This will permanently delete this finding and any associated false positive reports. +
+
+ + {/* Finding Info */} + {finding && ( +
+
+ Detection + + {finding.id.slice(0, 8)} + +
+

+ {finding.title} +

+

+ {finding.detector_name} +

+ {finding.player_name && ( +

+ Player: {finding.player_name} +

+ )} +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+ )} +
+
+ ); +} diff --git a/web/components/dashboard/view-false-positive-report-modal.tsx b/web/components/dashboard/view-false-positive-report-modal.tsx new file mode 100644 index 0000000..4ad4a12 --- /dev/null +++ b/web/components/dashboard/view-false-positive-report-modal.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + RiFlagFill, + RiCloseLine, + RiLoader4Line, + RiDeleteBinLine, + RiSave2Line, + RiFileCopyLine, + RiCheckLine, +} from "@remixicon/react"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import type { Finding } from "@/lib/api"; +import { createClient } from "@/lib/supabase/client"; + +interface FalsePositiveReport { + id: string; + finding_id: string; + server_id: string; + player_activity: string | null; + suspected_cause: string | null; + additional_context: string | null; + created_at: string; + reporter_user_id: string | null; +} + +interface ViewFalsePositiveReportModalProps { + finding: Finding; + onClose: () => void; + serverId: string; + onReportDeleted?: (findingId: string) => void; + onReportUpdated?: () => void; +} + +export function ViewFalsePositiveReportModal({ + finding, + onClose, + serverId, + onReportDeleted, + onReportUpdated, +}: ViewFalsePositiveReportModalProps) { + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Edit state + const [isEditing, setIsEditing] = useState(false); + const [playerActivity, setPlayerActivity] = useState(""); + const [suspectedCause, setSuspectedCause] = useState(""); + const [additionalContext, setAdditionalContext] = useState(""); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [copied, setCopied] = useState(false); + + // Fetch the report on mount + useEffect(() => { + async function fetchReport() { + setLoading(true); + setError(null); + try { + const supabase = createClient(); + const { data, error: fetchError } = await supabase + .from("false_positive_reports") + .select("*") + .eq("finding_id", finding.id) + .eq("server_id", serverId) + .single(); + + if (fetchError) { + throw new Error(fetchError.message); + } + + setReport(data); + setPlayerActivity(data.player_activity || ""); + setSuspectedCause(data.suspected_cause || ""); + setAdditionalContext(data.additional_context || ""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch report"); + } finally { + setLoading(false); + } + } + + fetchReport(); + }, [finding.id, serverId]); + + const handleSave = async () => { + if (!report) return; + + setSaving(true); + setError(null); + + try { + const supabase = createClient(); + const { error: updateError } = await supabase + .from("false_positive_reports") + .update({ + player_activity: playerActivity || null, + suspected_cause: suspectedCause || null, + additional_context: additionalContext || null, + }) + .eq("id", report.id); + + if (updateError) { + throw new Error(updateError.message); + } + + setReport({ + ...report, + player_activity: playerActivity || null, + suspected_cause: suspectedCause || null, + additional_context: additionalContext || null, + }); + setIsEditing(false); + onReportUpdated?.(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save report"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!report) return; + + const confirmed = window.confirm( + "Are you sure you want to delete this false positive report?" + ); + if (!confirmed) return; + + setDeleting(true); + setError(null); + + try { + const supabase = createClient(); + const { error: deleteError } = await supabase + .from("false_positive_reports") + .delete() + .eq("id", report.id); + + if (deleteError) { + throw new Error(deleteError.message); + } + + onReportDeleted?.(finding.id); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete report"); + } finally { + setDeleting(false); + } + }; + + const copyFindingId = async () => { + try { + await navigator.clipboard.writeText(finding.id); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for older browsers + const textArea = document.createElement("textarea"); + textArea.value = finding.id; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const cancelEdit = () => { + if (report) { + setPlayerActivity(report.player_activity || ""); + setSuspectedCause(report.suspected_cause || ""); + setAdditionalContext(report.additional_context || ""); + } + setIsEditing(false); + }; + + return ( + <> + {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+
+ +
+
+

+ False Positive Report +

+

+ Reported detection details +

+
+
+ +
+ + {/* Content */} +
+ {loading ? ( +
+ +
+ ) : error && !report ? ( +
+ {error} +
+ ) : ( + <> + {/* Finding Info */} +
+
+ Detection + +
+

+ {finding.title} +

+

+ {finding.detector_name} +

+
+ + Player: {finding.player_name || "Unknown"} + + + + {finding.occurrences && finding.occurrences > 1 + ? `${finding.occurrences} occurrences` + : "1 occurrence"} + +
+
+ + {/* Report Details */} + {isEditing ? ( + <> +
+ +