diff --git a/.env.example b/.env.example index 18437ed..97b3311 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,6 @@ VITE_API_BASE_URL=https://api.modl.gg # Stripe (public key only - safe to expose) VITE_STRIPE_PUBLISHABLE_KEY=pk_live_xxx + +# Realtime WebSocket client. Default ON. Set to false to hard-disable (kill-switch). +# VITE_REALTIME_ENABLED=false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c19e0d..ff433d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,10 +11,9 @@ on: jobs: build: runs-on: ubuntu-latest - + permissions: contents: read - packages: read steps: - name: Checkout code @@ -24,21 +23,15 @@ jobs: uses: actions/setup-node@v4 with: node-version: '23' - registry-url: 'https://npm.pkg.github.com' - scope: '@modl-gg' - - name: Configure npm for GitHub Packages + - name: Configure npm for Nexus run: | - rm .npmrc - echo "@modl-gg:registry=https://npm.pkg.github.com" >> .npmrc - echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" >> .npmrc - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + echo "//nexus.modl.gg/repository/npm-releases/:_auth=$(echo -n '${{ secrets.NEXUS_USER }}:${{ secrets.NEXUS_PASS }}' | base64)" >> ~/.npmrc + echo "//nexus.modl.gg/repository/npm-releases/:always-auth=true" >> ~/.npmrc + echo "@modl-gg:registry=https://nexus.modl.gg/repository/npm-releases/" >> ~/.npmrc - name: Install dependencies run: npm i - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run build run: npm run build diff --git a/client/index.html b/client/index.html index 0431adb..8363223 100644 --- a/client/index.html +++ b/client/index.html @@ -3,12 +3,13 @@ - Loading... + modl - + +
diff --git a/client/src/App.tsx b/client/src/App.tsx index 4ec6673..8984e19 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -12,6 +12,7 @@ import { useIsMobile } from '@modl-gg/shared-web/hooks/use-mobile'; import { useDocumentTitle } from "@/hooks/use-document-title"; import { useProvisioningStatusCheck } from "@/hooks/use-provisioning-status"; import { usePublicSettings } from "@/hooks/use-public-settings"; +import { RealtimeProvider } from "@/hooks/use-realtime"; import { PlayerWindowProvider } from "@/contexts/PlayerWindowContext"; import { WelcomeModal } from "@/components/layout/WelcomeModal"; import { Loader2 } from "lucide-react"; @@ -66,8 +67,9 @@ function Router() { const isUploadEvidencePage = location.startsWith('/upload-evidence'); const isVerifyPage = location.startsWith('/verify/'); const isReplayPage = location.startsWith('/replay'); + const isRateLimitPage = location.startsWith('/rate-limit'); - if (!isAdminPanelRoute && !isAuthPage && !isAppealsPage && !isPlayerTicketPage && !isSubmitTicketPage && !isProvisioningPage && !isAcceptInvitationPage && !isVerifyEmailPage && !isUploadEvidencePage && !isVerifyPage && !isReplayPage) { + if (!isAdminPanelRoute && !isAuthPage && !isAppealsPage && !isPlayerTicketPage && !isSubmitTicketPage && !isProvisioningPage && !isAcceptInvitationPage && !isVerifyEmailPage && !isUploadEvidencePage && !isVerifyPage && !isReplayPage && !isRateLimitPage) { return (
}> @@ -82,7 +84,7 @@ function Router() { ); } - if (isAuthPage || isAppealsPage || isPlayerTicketPage || isSubmitTicketPage || isProvisioningPage || isAcceptInvitationPage || isVerifyEmailPage || isUploadEvidencePage || isVerifyPage || isReplayPage) { + if (isAuthPage || isAppealsPage || isPlayerTicketPage || isSubmitTicketPage || isProvisioningPage || isAcceptInvitationPage || isVerifyEmailPage || isUploadEvidencePage || isVerifyPage || isReplayPage || isRateLimitPage) { return (
}> @@ -99,6 +101,7 @@ function Router() { +
@@ -186,9 +189,20 @@ function Router() { ); } +// Recovery/verification flows that must stay reachable even when the tenant is not fully +// provisioned (serverExists===false) or the platform is in maintenance mode. +const ALWAYS_REACHABLE_PATHS = ['/verify-email', '/verify/', '/replay']; +const isAlwaysReachablePath = (loc: string) => + ALWAYS_REACHABLE_PATHS.some((p) => loc.startsWith(p)); + function AppContent() { const { user, isLoading } = useAuth(); - const { data: publicSettings, isLoading: isLoadingSettings } = usePublicSettings(); + const { + data: publicSettings, + isLoading: isLoadingSettings, + isError: isSettingsError, + refetch: refetchSettings, + } = usePublicSettings(); const [location] = useLocation(); const [isWelcomeModalOpen, setWelcomeModalOpen] = useState(false); @@ -216,7 +230,7 @@ function AppContent() { if (!hasSeenModal && isOnPanelHomePage && !isFromProvisioning && !isOnExcludedPage && !isOnPlayerTicketPage && !isOnAcceptInvitationPage && user?.role === 'Super Admin') { setWelcomeModalOpen(true); } - }, [location, user]); + }, [location, user?.role]); const handleCloseWelcomeModal = () => { localStorage.setItem("hasSeenWelcomeModal", "true"); @@ -231,8 +245,30 @@ function AppContent() { ); } + // A transport/network failure of /v1/public/settings is transient, NOT a real "server not found". + // Only collapse to an error screen when we have no last-good settings to fall back to; otherwise the + // stale-but-valid `publicSettings` keeps the app usable (e.g. an authenticated staff session) and the + // next successful refetch self-heals. Never coerce a fetch failure into serverExists:false. + if (isSettingsError && !publicSettings && !isAlwaysReachablePath(location)) { + return ( +
+

Unable to reach the server

+

+ We couldn't load this page right now. This is usually temporary — please check your connection and try again. +

+ +
+ ); + } + // Skip server-not-found check for verify-email/verify/replay pages (needed for email verification flow) - if (publicSettings?.serverExists === false && !location.startsWith('/verify-email') && !location.startsWith('/verify/') && !location.startsWith('/replay')) { + if (publicSettings?.serverExists === false && !isAlwaysReachablePath(location)) { return ( }> @@ -240,12 +276,14 @@ function AppContent() { ); } - if (maintenanceMode) { + // Keep recovery/verification flows reachable during maintenance, matching the serverExists carve-out above. + if (maintenanceMode && !isAlwaysReachablePath(location)) { return ; } return ( <> + diff --git a/client/src/components/ArticleMediaUpload.tsx b/client/src/components/ArticleMediaUpload.tsx index 557b7e2..885f93a 100644 --- a/client/src/components/ArticleMediaUpload.tsx +++ b/client/src/components/ArticleMediaUpload.tsx @@ -1,11 +1,12 @@ import React, { useState } from 'react'; -import { Image as ImageIcon, Upload, Copy, Check, Trash2 } from 'lucide-react'; +import { Image as ImageIcon, Upload, Copy, Check, Trash2, Loader2 } from 'lucide-react'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; import { Badge } from '@modl-gg/shared-web/components/ui/badge'; import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import { Input } from '@modl-gg/shared-web/components/ui/input'; import { Label } from '@modl-gg/shared-web/components/ui/label'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@modl-gg/shared-web/components/ui/alert-dialog'; import MediaUpload from './MediaUpload'; import { useMediaUpload } from '@/hooks/use-media-upload'; import { formatFileSize } from '@/utils/file-utils'; @@ -42,6 +43,7 @@ export function ArticleMediaUpload({ const { t } = useTranslation(); const [media, setMedia] = useState(existingMedia); const [copiedUrl, setCopiedUrl] = useState(null); + const [deletingIds, setDeletingIds] = useState>(new Set()); const { config, deleteMedia } = useMediaUpload(); const { toast } = useToast(); @@ -50,7 +52,7 @@ export function ArticleMediaUpload({ const fileName = file?.name || result.url.split('/').pop() || 'uploaded-file'; const newMedia: ArticleMedia = { - id: Date.now().toString(), + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, url: result.url, key: result.key, fileName: fileName, @@ -60,9 +62,11 @@ export function ArticleMediaUpload({ altText: fileName.split('.')[0] // Use filename without extension as default alt text }; - const updatedMedia = [...media, newMedia]; - setMedia(updatedMedia); - onMediaUpdate?.(updatedMedia); + setMedia(prev => { + const updatedMedia = [...prev, newMedia]; + onMediaUpdate?.(updatedMedia); + return updatedMedia; + }); toast({ title: t('upload.mediaUploaded'), @@ -71,6 +75,11 @@ export function ArticleMediaUpload({ }; const handleDeleteMedia = async (mediaItem: ArticleMedia) => { + setDeletingIds(prev => { + const next = new Set(prev); + next.add(mediaItem.id); + return next; + }); try { await deleteMedia(mediaItem.key); const updatedMedia = media.filter(m => m.id !== mediaItem.id); @@ -87,6 +96,12 @@ export function ArticleMediaUpload({ description: t('upload.deleteMediaFailed'), variant: "destructive", }); + } finally { + setDeletingIds(prev => { + const next = new Set(prev); + next.delete(mediaItem.id); + return next; + }); } }; @@ -200,14 +215,40 @@ export function ArticleMediaUpload({ )} {!readonly && ( - + + + + + + + {t('upload.deleteMediaTitle', 'Delete media?')} + + {t('upload.deleteMediaConfirm', { name: item.fileName, defaultValue: 'This will permanently remove "{{name}}". Articles referencing this media will show a broken image.' })} + + + + {t('common.cancel')} + handleDeleteMedia(item)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t('common.delete')} + + + + )} diff --git a/client/src/components/EvidenceUpload.tsx b/client/src/components/EvidenceUpload.tsx index f818d15..1789f4e 100644 --- a/client/src/components/EvidenceUpload.tsx +++ b/client/src/components/EvidenceUpload.tsx @@ -1,14 +1,16 @@ import React, { useState } from 'react'; -import { Shield, FileText, Image, Video, Eye, Trash2 } from 'lucide-react'; +import { Shield, FileText, Image, Video, Eye, Trash2, Loader2 } from 'lucide-react'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@modl-gg/shared-web/components/ui/dialog'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@modl-gg/shared-web/components/ui/alert-dialog'; import { Badge } from '@modl-gg/shared-web/components/ui/badge'; import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import MediaUpload from './MediaUpload'; import { useMediaUpload } from '@/hooks/use-media-upload'; import { formatFileSize } from '@/utils/file-utils'; import { useTranslation } from 'react-i18next'; +import { useAuth } from '@/hooks/use-auth'; interface EvidenceItem { id: string; @@ -40,7 +42,9 @@ export function EvidenceUpload({ readonly = false }: EvidenceUploadProps) { const { t } = useTranslation(); + const { user } = useAuth(); const [evidence, setEvidence] = useState(existingEvidence); + const [deletingIds, setDeletingIds] = useState>(new Set()); const { deleteMedia } = useMediaUpload(); const { toast } = useToast(); @@ -49,20 +53,22 @@ export function EvidenceUpload({ const fileName = file?.name || result.url.split('/').pop() || 'uploaded-file'; const newEvidence: EvidenceItem = { - id: Date.now().toString(), + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, url: result.url, key: result.key, fileName: fileName, fileType: file?.type || 'application/octet-stream', fileSize: file?.size || 0, uploadedAt: new Date().toISOString(), - uploadedBy: 'Current User', // This should come from auth context + uploadedBy: user?.username || user?.email || 'Unknown', category }; - const updatedEvidence = [...evidence, newEvidence]; - setEvidence(updatedEvidence); - onEvidenceUpdate?.(updatedEvidence); + setEvidence(prev => { + const updatedEvidence = [...prev, newEvidence]; + onEvidenceUpdate?.(updatedEvidence); + return updatedEvidence; + }); toast({ title: t('upload.evidenceUploaded'), @@ -71,6 +77,11 @@ export function EvidenceUpload({ }; const handleDeleteEvidence = async (evidenceItem: EvidenceItem) => { + setDeletingIds(prev => { + const next = new Set(prev); + next.add(evidenceItem.id); + return next; + }); try { await deleteMedia(evidenceItem.key); const updatedEvidence = evidence.filter(e => e.id !== evidenceItem.id); @@ -87,6 +98,12 @@ export function EvidenceUpload({ description: t('upload.deleteEvidenceFailed'), variant: "destructive", }); + } finally { + setDeletingIds(prev => { + const next = new Set(prev); + next.delete(evidenceItem.id); + return next; + }); } }; @@ -184,13 +201,39 @@ export function EvidenceUpload({ {!readonly && ( - + + + + + + + {t('upload.deleteEvidenceTitle', 'Delete evidence?')} + + {t('upload.deleteEvidenceConfirm', { name: item.fileName, defaultValue: 'This will permanently remove "{{name}}". This action cannot be undone.' })} + + + + {t('common.cancel')} + handleDeleteEvidence(item)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t('common.delete')} + + + + )} diff --git a/client/src/components/MediaUpload.tsx b/client/src/components/MediaUpload.tsx index 9044b16..f656c6e 100644 --- a/client/src/components/MediaUpload.tsx +++ b/client/src/components/MediaUpload.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useCallback, useMemo } from 'react'; +import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { Upload, X, FileText, Image, Video, File, Loader2, Check, AlertCircle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '@modl-gg/shared-web/components/ui/button'; @@ -86,10 +86,18 @@ export function MediaUpload({ const [uploadedFiles, setUploadedFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); + const isMountedRef = useRef(true); const { toast } = useToast(); const { uploadMedia } = useMediaUpload(); const { data: mediaConfig } = useMediaUploadConfig(); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + const isUploading = uploadedFiles.some(f => f.status === 'uploading'); const configKey = uploadTypeToConfigKey(uploadType); @@ -128,6 +136,7 @@ export function MediaUpload({ const uploadFile = async (file: File, fileId: string): Promise<{ url: string; key: string } | null> => { const result = await uploadMedia(file, uploadType, metadata, (progress) => { + if (!isMountedRef.current) return; setUploadedFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: progress.percentage } @@ -165,8 +174,24 @@ export function MediaUpload({ return; } - const newFiles = Array.from(files).slice(0, maxFiles - uploadedFiles.length); - + // Only count in-flight + successfully uploaded files toward the cap. + // Validation-error rows are stale UI artifacts and must not shrink the + // available slots for a subsequent valid selection. + const activeCount = uploadedFiles.filter(f => f.status !== 'error').length; + const remainingSlots = Math.max(0, maxFiles - activeCount); + const newFiles = Array.from(files).slice(0, remainingSlots); + + if (newFiles.length < files.length) { + toast({ + title: t('upload.maxFilesReached', 'Maximum files reached'), + description: t('upload.maxFilesReachedDesc', { + count: maxFiles, + defaultValue: 'You can upload at most {{count}} files. Some files were not added.', + }), + variant: "destructive", + }); + } + for (const file of newFiles) { const fileId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -203,34 +228,40 @@ export function MediaUpload({ try { const result = await uploadFile(file, fileId); - + + // If the component unmounted while this upload was in flight, do not + // touch React state or fire parent callbacks against a stale parent. + if (!isMountedRef.current) continue; + if (result) { // Update file status to success - setUploadedFiles(prev => prev.map(f => - f.id === fileId + setUploadedFiles(prev => prev.map(f => + f.id === fileId ? { ...f, status: 'success', progress: 100, url: result.url, key: result.key } : f )); - + onUploadComplete?.(result, file); - + toast({ title: t('upload.uploadSuccessful'), description: t('upload.uploadedSuccess', { name: file.name }), }); } } catch (error) { + if (!isMountedRef.current) continue; + const errorMessage = error instanceof Error ? error.message : 'Upload failed'; - + // Update file status to error - setUploadedFiles(prev => prev.map(f => - f.id === fileId + setUploadedFiles(prev => prev.map(f => + f.id === fileId ? { ...f, status: 'error', progress: 0, error: errorMessage } : f )); - + onUploadError?.(errorMessage); - + toast({ title: t('upload.uploadFailed'), description: t('upload.uploadFailedDesc', { name: file.name, error: errorMessage }), diff --git a/client/src/components/TicketAttachments.tsx b/client/src/components/TicketAttachments.tsx index bb2b79c..b169804 100644 --- a/client/src/components/TicketAttachments.tsx +++ b/client/src/components/TicketAttachments.tsx @@ -1,9 +1,10 @@ import React, { useState } from 'react'; -import { Paperclip, Download, Eye, Trash2, FileText, Image, Video, File } from 'lucide-react'; +import { Paperclip, Download, Eye, Trash2, FileText, Image, Video, File, Loader2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@modl-gg/shared-web/components/ui/dialog'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@modl-gg/shared-web/components/ui/alert-dialog'; import { Badge } from '@modl-gg/shared-web/components/ui/badge'; import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import MediaUpload from './MediaUpload'; @@ -43,6 +44,7 @@ export function TicketAttachments({ }: TicketAttachmentsProps & { compact?: boolean }) { const { t } = useTranslation(); const [attachments, setAttachments] = useState(existingAttachments); + const [deletingIds, setDeletingIds] = useState>(new Set()); const { config, deleteMedia } = useMediaUpload(); const { toast } = useToast(); @@ -51,7 +53,7 @@ export function TicketAttachments({ const fileName = file?.name || result.url.split('/').pop() || 'uploaded-file'; const newAttachment: TicketAttachment = { - id: Date.now().toString(), + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, url: result.url, key: result.key, fileName: fileName, @@ -61,9 +63,11 @@ export function TicketAttachments({ uploadedBy: 'Current User' // This should come from auth context }; - const updatedAttachments = [...attachments, newAttachment]; - setAttachments(updatedAttachments); - onAttachmentsUpdate?.(updatedAttachments); + setAttachments(prev => { + const updatedAttachments = [...prev, newAttachment]; + onAttachmentsUpdate?.(updatedAttachments); + return updatedAttachments; + }); toast({ title: t('upload.attachmentUploaded'), @@ -85,6 +89,11 @@ export function TicketAttachments({ return; } + setDeletingIds(prev => { + const next = new Set(prev); + next.add(attachment.id); + return next; + }); try { await deleteMedia(attachment.key); const updatedAttachments = attachments.filter(a => a.id !== attachment.id); @@ -101,6 +110,12 @@ export function TicketAttachments({ description: t('upload.deleteAttachmentFailed'), variant: "destructive", }); + } finally { + setDeletingIds(prev => { + const next = new Set(prev); + next.delete(attachment.id); + return next; + }); } }; @@ -181,13 +196,52 @@ export function TicketAttachments({ {getFileIcon(attachment.fileType)} {truncatedName} {!readonly && ( - + publicMode ? ( + + ) : ( + + + + + + + {t('upload.deleteAttachmentTitle', 'Delete attachment?')} + + {t('upload.deleteAttachmentConfirm', { name: attachment.fileName, defaultValue: 'This will permanently remove "{{name}}". This action cannot be undone.' })} + + + + {t('common.cancel')} + handleDeleteAttachment(attachment)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t('common.delete')} + + + + + ) )} ); @@ -265,14 +319,52 @@ export function TicketAttachments({ {!readonly && ( - + publicMode ? ( + + ) : ( + + + + + + + {t('upload.deleteAttachmentTitle', 'Delete attachment?')} + + {t('upload.deleteAttachmentConfirm', { name: attachment.fileName, defaultValue: 'This will permanently remove "{{name}}". This action cannot be undone.' })} + + + + {t('common.cancel')} + handleDeleteAttachment(attachment)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t('common.delete')} + + + + + ) )} diff --git a/client/src/components/dashboard/AssignedTicketUpdatesSection.tsx b/client/src/components/dashboard/AssignedTicketUpdatesSection.tsx index 5c7400a..47dc3f3 100644 --- a/client/src/components/dashboard/AssignedTicketUpdatesSection.tsx +++ b/client/src/components/dashboard/AssignedTicketUpdatesSection.tsx @@ -7,6 +7,7 @@ import { useLocation } from 'wouter'; import { formatTimeAgo } from '@/utils/date-utils'; import { stripMarkdown } from '@/utils/markdown-utils'; import { useTranslation } from 'react-i18next'; +import { StatusBadge } from '@/components/ui/status-badge'; const INITIAL_VISIBLE = 2; const LOAD_MORE_COUNT = 2; @@ -113,7 +114,7 @@ export function AssignedTicketUpdatesSection({ {updates.slice(0, visibleCount).map((update) => (
handleTicketClick(update.ticketId)} >
@@ -121,26 +122,23 @@ export function AssignedTicketUpdatesSection({ {update.ticketTitle}
-
-
+ {update.isStaffReply ? t('dashboard.assignedTickets.staff') : t('dashboard.assignedTickets.player')} - + diff --git a/client/src/components/dashboard/DashboardAlertsSection.tsx b/client/src/components/dashboard/DashboardAlertsSection.tsx new file mode 100644 index 0000000..fbc05d0 --- /dev/null +++ b/client/src/components/dashboard/DashboardAlertsSection.tsx @@ -0,0 +1,76 @@ +import { StatusBanner } from '@modl-gg/shared-web/components/ui/status-banner'; +import { useTranslation } from 'react-i18next'; +import ReactMarkdown from 'react-markdown'; +import type { SystemAlert, SystemAlertSeverity } from '@/hooks/use-data'; + +interface DashboardAlertsSectionProps { + alerts?: SystemAlert[]; +} + +type AlertBannerVariant = 'info' | 'warning' | 'error'; + +const severityVariant: Record = { + BASIC: 'info', + WARNING: 'warning', + CRITICAL: 'error', +}; + +function normalizeSeverity(severity: unknown): string | null { + if (typeof severity !== 'string' || severity.trim().length === 0) { + return null; + } + + return severity.trim(); +} + +function isKnownSeverity(severity: string): severity is SystemAlertSeverity { + return severity === 'BASIC' || severity === 'WARNING' || severity === 'CRITICAL'; +} + +function getSeverityVariant(severity: unknown): AlertBannerVariant { + const normalizedSeverity = normalizeSeverity(severity); + if (!normalizedSeverity || !isKnownSeverity(normalizedSeverity)) { + return 'info'; + } + + return severityVariant[normalizedSeverity]; +} + +function getSeverityTitle(severity: unknown, t: ReturnType['t']) { + const normalizedSeverity = normalizeSeverity(severity); + + if (!normalizedSeverity) { + return t('dashboard.alerts.unknownSeverity', { severity: t('dashboard.alerts.unknownSeverityValue') }); + } + + if (isKnownSeverity(normalizedSeverity)) { + return t(`dashboard.alerts.levels.${normalizedSeverity}`); + } + + return t('dashboard.alerts.unknownSeverity', { severity: normalizedSeverity }); +} + +export function DashboardAlertsSection({ alerts = [] }: DashboardAlertsSectionProps) { + const { t } = useTranslation(); + + if (alerts.length === 0) { + return null; + } + + return ( +
+ {alerts.map((alert) => ( + +
+ {alert.message} +
+
+ ))} +
+ ); +} diff --git a/client/src/components/dashboard/RecentPunishmentsSection.tsx b/client/src/components/dashboard/RecentPunishmentsSection.tsx index cf1b2d4..8fe80aa 100644 --- a/client/src/components/dashboard/RecentPunishmentsSection.tsx +++ b/client/src/components/dashboard/RecentPunishmentsSection.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; import { Badge } from '@modl-gg/shared-web/components/ui/badge'; import { Button } from '@modl-gg/shared-web/components/ui/button'; -import { Shield, Clock, User, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'; +import { Shield, User, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'; import { usePlayerWindow } from '@/contexts/PlayerWindowContext'; import { formatTimeAgo } from '@/utils/date-utils'; import { useTranslation } from 'react-i18next'; @@ -13,7 +13,6 @@ export interface RecentPunishment { playerName: string; playerUuid: string; reason: string; - duration?: string | number; issuerName: string; issued: string | Date; active: boolean; @@ -43,23 +42,6 @@ export function RecentPunishmentsSection({ punishments, loading }: RecentPunishm setExpandedPunishments(newExpanded); }; - const formatDuration = (duration?: string | number) => { - if (!duration) return t('dashboard.recentPunishments.permanent'); - - // Convert to string if it's a number - const durationStr = String(duration); - - // Parse duration like "30d", "2h", "1w" - const match = durationStr.match(/^(\d+)([dhm])$/); - if (!match) return durationStr; - - const [, amount, unit] = match; - const unitNames = { d: 'day', h: 'hour', m: 'minute' }; - const unitName = unitNames[unit as keyof typeof unitNames]; - - return `${amount} ${unitName}${parseInt(amount) > 1 ? 's' : ''}`; - }; - if (loading) { return ( @@ -156,7 +138,7 @@ export function RecentPunishmentsSection({ punishments, loading }: RecentPunishm {punishment.playerName} - {punishment.type || t('dashboard.recentPunishments.punishment')} {t('dashboard.recentPunishments.by')} {punishment.issuerName || t('search.unknown')} + {punishment.type || t('dashboard.recentPunishments.punishment')} {t('dashboard.recentPunishments.by', { issuer: punishment.issuerName || t('search.unknown') })}
@@ -169,16 +151,6 @@ export function RecentPunishmentsSection({ punishments, loading }: RecentPunishm

{punishment.reason || t('dashboard.recentPunishments.noReason')}

- {punishment.duration && ( -
- {t('dashboard.recentPunishments.duration')} -
- - {formatDuration(punishment.duration)} -
-
- )} -
diff --git a/client/src/components/dashboard/RecentTicketsSection.tsx b/client/src/components/dashboard/RecentTicketsSection.tsx index c6bc689..6f9a304 100644 --- a/client/src/components/dashboard/RecentTicketsSection.tsx +++ b/client/src/components/dashboard/RecentTicketsSection.tsx @@ -5,6 +5,23 @@ import { useLocation } from 'wouter'; import { formatTimeAgo } from '@/utils/date-utils'; import { stripMarkdown } from '@/utils/markdown-utils'; import { useTranslation } from 'react-i18next'; +import { StatusBadge } from '@/components/ui/status-badge'; + +type Intent = 'info' | 'success' | 'warning' | 'destructive' | 'neutral'; + +const statusIntents: Record = { + open: 'info', + closed: 'success', + unfinished: 'neutral', +}; + +const priorityIntents: Record = { + low: 'neutral', + normal: 'info', + medium: 'info', + high: 'warning', + urgent: 'destructive', +}; export interface RecentTicket { id: string; @@ -22,20 +39,6 @@ interface RecentTicketsSectionProps { loading: boolean; } -const statusColors: Record = { - open: 'bg-blue-500/20 text-blue-500', - closed: 'bg-green-500/20 text-green-500', - unfinished: 'bg-gray-500/20 text-gray-500' -}; - -const priorityColors: Record = { - low: 'bg-gray-500/20 text-gray-500', - normal: 'bg-blue-500/20 text-blue-500', - medium: 'bg-blue-500/20 text-blue-500', - high: 'bg-orange-500/20 text-orange-500', - urgent: 'bg-red-500/20 text-red-500' -}; - export function RecentTicketsSection({ tickets, loading }: RecentTicketsSectionProps) { const { t } = useTranslation(); const [, setLocation] = useLocation(); @@ -44,6 +47,18 @@ export function RecentTicketsSection({ tickets, loading }: RecentTicketsSectionP setLocation(`/panel/tickets/${ticketId}`); }; + // A ticket missing its creation timestamp arrives as epoch 0 (new Date(0)), + // which is a valid Date so formatTimeAgo would render a "decades ago" value. + // Treat epoch 0 / falsy timestamps as unknown instead. + const formatCreatedAt = (createdAt: string | Date) => { + if (!createdAt) return t('search.unknown'); + const date = new Date(createdAt); + if (isNaN(date.getTime()) || date.getTime() === 0) { + return t('search.unknown'); + } + return formatTimeAgo(createdAt); + }; + const truncateMessage = (message: string | undefined | null, maxLength: number = 120) => { if (!message) return t('dashboard.recentTickets.noMessage'); const messageStr = stripMarkdown(String(message)); @@ -102,14 +117,14 @@ export function RecentTicketsSection({ tickets, loading }: RecentTicketsSectionP

{ticket.title}

{ticket.priority && ( - + {ticket.priority.toUpperCase()} - + )} {ticket.status && ( - + {ticket.status.replace('_', ' ').toUpperCase()} - + )}
@@ -126,7 +141,7 @@ export function RecentTicketsSection({ tickets, loading }: RecentTicketsSectionP
- {formatTimeAgo(ticket.createdAt)} + {formatCreatedAt(ticket.createdAt)}
{ticket.type && ( diff --git a/client/src/components/dashboard/TicketSubscriptionsSection.tsx b/client/src/components/dashboard/TicketSubscriptionsSection.tsx index b29c791..7aeb9e8 100644 --- a/client/src/components/dashboard/TicketSubscriptionsSection.tsx +++ b/client/src/components/dashboard/TicketSubscriptionsSection.tsx @@ -6,6 +6,7 @@ import { useLocation } from 'wouter'; import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import { formatTimeAgo } from '@/utils/date-utils'; import { useTranslation } from 'react-i18next'; +import { StatusBadge } from '@/components/ui/status-badge'; export interface TicketSubscriptionUpdate { id: string; @@ -128,7 +129,7 @@ export function TicketSubscriptionsSection({ updates.slice(0, 5).map((update) => (
{ handleTicketClick(update.ticketId); }} @@ -138,26 +139,23 @@ export function TicketSubscriptionsSection({ {update.ticketTitle}
-
-
+ {update.isStaffReply ? t('dashboard.subscriptions.staff') : t('dashboard.subscriptions.player')} - + @@ -170,6 +168,7 @@ export function TicketSubscriptionsSection({ handleUnsubscribe(update.ticketId, update.ticketTitle); }} title="Unsubscribe" + aria-label="Unsubscribe" > diff --git a/client/src/components/knowledgebase/CategoryDisplay.tsx b/client/src/components/knowledgebase/CategoryDisplay.tsx index e4b576b..2911b34 100644 --- a/client/src/components/knowledgebase/CategoryDisplay.tsx +++ b/client/src/components/knowledgebase/CategoryDisplay.tsx @@ -18,20 +18,20 @@ interface CategoryDisplayProps { const CategoryDisplay: React.FC = ({ name, description, articles, slug }) => { return (
-

{name}

- {description &&

{description}

} +

{name}

+ {description &&

{description}

} {articles && articles.length > 0 ? (
    {articles.map(article => (
  • - + {article.title}
  • ))}
) : ( -

No articles in this category yet.

+

No articles in this category yet.

)}
); diff --git a/client/src/components/layout/MobileNavbar.tsx b/client/src/components/layout/MobileNavbar.tsx index af3503a..8b0af52 100644 --- a/client/src/components/layout/MobileNavbar.tsx +++ b/client/src/components/layout/MobileNavbar.tsx @@ -7,10 +7,20 @@ import { AlertCircle, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { useIsFetching } from '@tanstack/react-query'; +import { usePermissions, PERMISSIONS } from '@/hooks/use-permissions'; +import { useAuth } from '@/hooks/use-auth'; const MobileNavbar = () => { const { t } = useTranslation(); const [location, navigate] = useLocation(); + const { hasPermission } = usePermissions(); + const { user } = useAuth(); + + // Mirror Sidebar's loading-flash guard so permission-gated items don't appear + // before the user's permissions have loaded. + const fetchingCount = useIsFetching({ queryKey: ['userPermissions', user?.role] }); + const permissionsLoading = fetchingCount > 0; const isActive = (path: string) => { return location === path @@ -18,7 +28,7 @@ const MobileNavbar = () => { : "text-muted-foreground"; }; - const navItems = [ + const allNavItems = [ { icon: , label: t('nav.home'), @@ -41,6 +51,7 @@ const MobileNavbar = () => { icon: , label: t('nav.audit'), path: "/panel/audit", + permission: PERMISSIONS.ADMIN_AUDIT_VIEW, onClick: () => navigate('/panel/audit') }, { @@ -51,9 +62,16 @@ const MobileNavbar = () => { } ]; + // Filter nav items based on permissions, mirroring Sidebar's gating + const navItems = allNavItems.filter(item => { + if (!item.permission) return true; + if (permissionsLoading) return false; + return hasPermission(item.permission); + }); + return (
-
+
{navItems.map((item, index) => ( (null); const headerRef = useRef(null); const minimizedHeaderRef = useRef(null); + const initialPositionRef = useRef(initialPosition); const [isDragging, setIsDragging] = useState(false); const [position, setPosition] = useState(initialPosition); const [size, setSize] = useState(initialSize); + const positionRef = useRef(position); + const sizeRef = useRef(size); const [isMaximized, setIsMaximized] = useState(false); const [isMinimized, setIsMinimized] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); @@ -51,62 +54,72 @@ const ResizableWindow = ({ }>({ position: initialPosition, size: initialSize }); const [isInitialized, setIsInitialized] = useState(false); + useEffect(() => { + positionRef.current = position; + }, [position]); + + useEffect(() => { + sizeRef.current = size; + }, [size]); + // Initialize position once useEffect(() => { if (!isOpen || !windowRef.current || isInitialized) return; - -// Use the global last known window configuration for player windows + + const initial = initialPositionRef.current; + + // Use the global last known window configuration for player windows if (id.startsWith('player-')) { - // Apply the size from the last window + // Apply the size from the last window setSize(lastPlayerWindowConfig.size); - + // Stagger the position slightly for multiple windows const offset = 20; const totalOpen = document.querySelectorAll('.resizable-window').length - 1; - - if (typeof lastPlayerWindowConfig.position.x === 'number' && + + if (typeof lastPlayerWindowConfig.position.x === 'number' && typeof lastPlayerWindowConfig.position.y === 'number') { - setPosition({ + setPosition({ x: lastPlayerWindowConfig.position.x + (offset * totalOpen), y: lastPlayerWindowConfig.position.y + (offset * totalOpen) }); } else { setPosition(lastPlayerWindowConfig.position); } - + setIsInitialized(true); } // Center the window if initial position is percentage based - if (typeof initialPosition.x === 'string' && initialPosition.x.includes('%')) { + if (typeof initial.x === 'string' && initial.x.includes('%')) { const windowWidth = windowRef.current.offsetWidth; const windowHeight = windowRef.current.offsetHeight; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - + // Safe parsing of percentage values let xPercent = 0.5; // Default to 50% let yPercent = 0.5; // Default to 50% - - if (typeof initialPosition.x === 'string') { - const parsedX = parseFloat(initialPosition.x); + + if (typeof initial.x === 'string') { + const parsedX = parseFloat(initial.x); if (!isNaN(parsedX)) { xPercent = parsedX / 100; } } - - if (typeof initialPosition.y === 'string') { - const parsedY = parseFloat(initialPosition.y); + + if (typeof initial.y === 'string') { + const parsedY = parseFloat(initial.y); if (!isNaN(parsedY)) { yPercent = parsedY / 100; } } - + const xPos = viewportWidth * xPercent - windowWidth * xPercent; const yPos = viewportHeight * yPercent - windowHeight * yPercent; - + setPosition({ x: xPos, y: yPos }); setIsInitialized(true); - + // Store this as the last known configuration if it's a player window if (id.startsWith('player-')) { // Update the global config @@ -114,7 +127,7 @@ const ResizableWindow = ({ lastPlayerWindowConfig.position = { x: xPos, y: yPos }; } } - }, [isOpen, initialPosition, isInitialized, id]); + }, [isOpen, isInitialized, id]); // Handle dragging useEffect(() => { @@ -138,14 +151,14 @@ const ResizableWindow = ({ const handleMouseUp = () => { setIsDragging(false); - + // Save position for new windows if (id.startsWith('player-')) { - // Update the global window state - lastPlayerWindowConfig.size = size; + // Update the global window state using freshest values via refs + lastPlayerWindowConfig.size = sizeRef.current; lastPlayerWindowConfig.position = { - x: typeof position.x === 'number' ? position.x : 0, - y: typeof position.y === 'number' ? position.y : 0 + x: typeof positionRef.current.x === 'number' ? positionRef.current.x : 0, + y: typeof positionRef.current.y === 'number' ? positionRef.current.y : 0 }; } }; @@ -159,7 +172,7 @@ const ResizableWindow = ({ document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; - }, [isDragging, dragStart]); + }, [isDragging, dragStart, id]); const handleMouseDown = (e: React.MouseEvent) => { if (!windowRef.current) return; diff --git a/client/src/components/layout/Sidebar.tsx b/client/src/components/layout/Sidebar.tsx index c4e9d6e..148698f 100644 --- a/client/src/components/layout/Sidebar.tsx +++ b/client/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { useLocation } from "wouter"; import { cn } from "@/lib/utils"; import { useSidebar } from "@/hooks/use-sidebar"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Home, @@ -19,31 +19,53 @@ import { import { Button } from "@modl-gg/shared-web/components/ui/button"; import { Input } from "@modl-gg/shared-web/components/ui/input"; import { useDashboard } from "@/contexts/DashboardContext"; -import PlayerWindow from "../../components/windows/PlayerWindow"; +import { usePlayerWindow } from "@/contexts/PlayerWindowContext"; import serverLogo from "../../assets/server-logo.png"; import { usePublicSettings } from "@/hooks/use-public-settings"; import { usePunishmentLookup } from "@/hooks/use-player-lookup"; import { usePermissions, PERMISSIONS } from "@/hooks/use-permissions"; import { useAuth } from "@/hooks/use-auth"; import { usePlayerSearch } from "@/hooks/use-data"; -import { useQuery } from '@tanstack/react-query'; +import { useIsFetching } from '@tanstack/react-query'; import { queryClient } from "@/lib/queryClient"; -import { apiFetch } from "@/lib/api"; +import { protoFetch } from "@/lib/proto-fetch"; +import { toNum } from "@/lib/proto-ui"; +import { + DashboardRecentTicketsResponseSchema, + DashboardRecentPunishmentsResponseSchema, +} from "@modl-gg/proto/modl/v1/dashboard_pb.ts"; +import { PaginatedTicketsResponseSchema } from "@modl-gg/proto/modl/v1/ticket_pb.ts"; +import { PanelPunishmentTypesResponseSchema } from "@modl-gg/proto/modl/v1/settings_pb.ts"; + +interface Player { + uuid?: string; + minecraftUuid?: string; + username?: string; + status?: string; + lastOnline?: string; + isOnline?: boolean; + online?: boolean; + data?: any; +} + +const HOME_ICON = ; +const SEARCH_ICON = ; +const TICKET_ICON = ; +const AUDIT_ICON = ; +const SETTINGS_ICON = ; const Sidebar = () => { const { t } = useTranslation(); const { isSearchActive, setIsSearchActive } = useSidebar(); const { openLookupWindow: openDashboardLookupWindow } = useDashboard(); + const { openPlayerWindow: openPlayerWindowFromContext } = usePlayerWindow(); const [location, navigate] = useLocation(); const { data: publicSettings } = usePublicSettings(); const { hasPermission } = usePermissions(); const { user } = useAuth(); - - // Check if permissions are still loading from server - const { isLoading: permissionsLoading } = useQuery({ - queryKey: ['userPermissions', user?.role], - enabled: false, // We just want the loading state - }); + + const fetchingCount = useIsFetching({ queryKey: ['userPermissions', user?.role] }); + const permissionsLoading = fetchingCount > 0; const [isLookupOpen, setIsLookupOpen] = useState(false); const [isLookupClosing, setIsLookupClosing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -85,52 +107,34 @@ const Sidebar = () => { // Function to handle player selection and update recent searches const handlePlayerSelect = (player: Player) => { const uuid = getPlayerUuid(player); - + if (!uuid) return; // Guard against empty UUID - + // Update recent searches const timestamp = Date.now(); const existing = recentSearches.filter(s => getPlayerUuid(s.player) !== uuid); const newSearches = [{player, timestamp}, ...existing].slice(0, 10); setRecentSearches(newSearches); - + // Save to localStorage localStorage.setItem('recentPlayerSearches', JSON.stringify(newSearches)); - + // Open player window and close lookup const url = new URL(window.location.href); url.searchParams.set("player", uuid); window.history.pushState({}, "", url.toString()); - openPlayerWindow(uuid); + openPlayerWindow(uuid, player.username); closeLookup(); }; - // Track multiple windows with a map of id -> isOpen state - const [playerWindows, setPlayerWindows] = useState>( - {}, - ); const closeTimeoutRef = useRef(null); // Function to open player window when clicked from search - const openPlayerWindow = (playerId: string) => { - // Add this window to our tracked windows - setPlayerWindows((prev) => ({ - ...prev, - [playerId]: true, // Set this player's window to open - })); + const openPlayerWindow = (playerId: string, username?: string) => { + openPlayerWindowFromContext(playerId, username); openDashboardLookupWindow(); // Also open at dashboard level for tracking }; - // Function to close a specific player window - const closePlayerWindow = (playerId: string) => { - setPlayerWindows((prev) => { - // Create a new object excluding this player - const newWindows = { ...prev }; - delete newWindows[playerId]; - return newWindows; - }); - }; - const openLookup = () => { if (!isLookupOpen && !isLookupClosing) { setIsLookupOpen(true); @@ -191,14 +195,10 @@ const Sidebar = () => { const playerIdFromUrl = url.searchParams.get("player"); if (playerIdFromUrl) { - // Open this player's window - setPlayerWindows((prev) => ({ - ...prev, - [playerIdFromUrl]: true, - })); + openPlayerWindowFromContext(playerIdFromUrl); openDashboardLookupWindow(); } - }, [openDashboardLookupWindow]); + }, [openDashboardLookupWindow, openPlayerWindowFromContext]); // Clean up any timeouts when component unmounts useEffect(() => { @@ -209,48 +209,101 @@ const Sidebar = () => { }; }, []); + // IMPORTANT: prefetch must decode + map identically to the real data hooks + // (useRecentTickets/useRecentPunishments/useTickets/usePunishmentTypes), since + // react-query keys purely by queryKey. Storing the raw proto-JSON wrapper here + // would let a hover-then-navigate serve a wrong-shaped cached value and crash + // the consuming page. const prefetchRouteData = (path: string) => { - const prefetchFn = async (url: string) => { - const res = await apiFetch(url); - if (!res.ok) throw new Error('Prefetch failed'); - return res.json(); - }; - switch (path) { case '/panel': queryClient.prefetchQuery({ queryKey: ['/v1/panel/dashboard/recent-tickets', 3], - queryFn: () => prefetchFn('/v1/panel/dashboard/recent-tickets?limit=3'), + queryFn: async () => { + const res = await protoFetch( + DashboardRecentTicketsResponseSchema, + '/v1/panel/dashboard/recent-tickets?limit=3', + ); + return res.tickets.map((ticket) => ({ + id: ticket.id, + title: ticket.title, + initialMessage: ticket.initialMessage, + status: ticket.status, + priority: ticket.priority, + createdAt: new Date(toNum(ticket.createdAt)), + playerName: ticket.playerName, + type: ticket.type, + })); + }, staleTime: 2 * 60 * 1000, }); queryClient.prefetchQuery({ queryKey: ['/v1/panel/dashboard/recent-punishments', 5], - queryFn: () => prefetchFn('/v1/panel/dashboard/recent-punishments?limit=5'), + queryFn: async () => { + const res = await protoFetch( + DashboardRecentPunishmentsResponseSchema, + '/v1/panel/dashboard/recent-punishments?limit=5', + ); + return res.punishments.map((punishment) => ({ + id: punishment.id, + playerName: punishment.playerName, + playerUuid: punishment.playerUuid, + type: punishment.type, + reason: punishment.reason, + issuerName: punishment.issuerName, + issued: new Date(toNum(punishment.issued)), + active: punishment.active, + })); + }, staleTime: 2 * 60 * 1000, }); break; case '/panel/tickets': queryClient.prefetchQuery({ queryKey: ['/v1/panel/tickets', { page: 1, limit: 10, search: '', status: '', types: [], author: '', labels: [], assignees: [], sort: 'newest' }], - queryFn: () => prefetchFn('/v1/panel/tickets?page=1&limit=10&sort=newest'), + queryFn: async () => { + const response = await protoFetch( + PaginatedTicketsResponseSchema, + '/v1/panel/tickets?page=1&limit=10&sort=newest', + ); + return { + tickets: response.tickets.map((item) => ({ + ...item, + date: Number(item.date), + lastReply: item.lastReply + ? { ...item.lastReply, created: Number(item.lastReply.created) } + : undefined, + })), + pagination: response.pagination + ? { ...response.pagination, totalTickets: Number(response.pagination.totalTickets) } + : undefined, + filters: response.filters, + }; + }, staleTime: 30000, }); break; case '/panel/settings': queryClient.prefetchQuery({ queryKey: ['/v1/panel/settings/punishment-types'], - queryFn: () => prefetchFn('/v1/panel/settings/punishment-types'), + queryFn: async () => { + const response = await protoFetch( + PanelPunishmentTypesResponseSchema, + '/v1/panel/settings/punishment-types', + ); + return response.punishmentTypes as any[]; + }, staleTime: 5 * 60 * 1000, }); break; } }; - const allNavItems = [ + const allNavItems = useMemo(() => [ { name: t('nav.home'), path: "/panel", - icon: , + icon: HOME_ICON, onClick: () => { if (isLookupOpen) closeLookup(); navigate("/panel"); @@ -259,7 +312,7 @@ const Sidebar = () => { { name: t('nav.lookup'), path: "/panel/lookup", - icon: , + icon: SEARCH_ICON, onClick: () => { if (isLookupOpen) { closeLookup(); @@ -271,7 +324,7 @@ const Sidebar = () => { { name: t('nav.tickets'), path: "/panel/tickets", - icon: , + icon: TICKET_ICON, onClick: () => { if (isLookupOpen) closeLookup(); navigate("/panel/tickets"); @@ -280,7 +333,7 @@ const Sidebar = () => { { name: t('nav.audit'), path: "/panel/audit", - icon: , + icon: AUDIT_ICON, permission: PERMISSIONS.ADMIN_AUDIT_VIEW, onClick: () => { if (isLookupOpen) closeLookup(); @@ -290,13 +343,13 @@ const Sidebar = () => { { name: t('nav.settings'), path: "/panel/settings", - icon: , + icon: SETTINGS_ICON, onClick: () => { if (isLookupOpen) closeLookup(); navigate("/panel/settings"); }, }, - ]; + ], [t, isLookupOpen, navigate]); // Filter nav items based on permissions // Don't show permission-based items while permissions are loading to prevent flash @@ -309,17 +362,6 @@ const Sidebar = () => { // Calculate sidebar height dynamically (navItems * 56px + top/bottom padding of 32px) const sidebarHeight = Math.max(200, navItems.length * 56 + 16); - interface Player { - uuid?: string; - minecraftUuid?: string; - username?: string; - status?: string; - lastOnline?: string; - isOnline?: boolean; - online?: boolean; - data?: any; - } - const isPlayerOnline = (player: Player) => { if (player.status === 'Online') return true; if (player.isOnline !== undefined) return player.isOnline; @@ -559,9 +601,9 @@ const Sidebar = () => {
{t('search.playersFound')}
- {filteredLookups.map((player: Player, index: number) => ( + {filteredLookups.map((player: Player) => (
)} - {/* Render multiple player windows with offset positioning */} - {Object.entries(playerWindows).map(([playerId, isOpen], index) => ( - { - // Close this specific window - closePlayerWindow(playerId); - - // Update URL parameters - we keep other players in the URL - const url = new URL(window.location.href); - url.searchParams.delete("player"); - - // If we have other windows open, add the first one to the URL - const remainingPlayers = Object.keys(playerWindows).filter( - (id) => id !== playerId, - ); - if (remainingPlayers.length > 0) { - url.searchParams.set("player", remainingPlayers[0]); - } - - window.history.pushState({}, "", url.toString()); - }} - /> - ))}
); diff --git a/client/src/components/player-replays-list.tsx b/client/src/components/player-replays-list.tsx new file mode 100644 index 0000000..3fecf2e --- /dev/null +++ b/client/src/components/player-replays-list.tsx @@ -0,0 +1,129 @@ +import { Loader2, Play, TriangleAlert } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useLocation } from 'wouter'; +import { Badge } from '@modl-gg/shared-web/components/ui/badge'; +import { Button } from '@modl-gg/shared-web/components/ui/button'; +import { usePlayerReplays } from '@/hooks/use-data'; +import { formatDateWithTime } from '@/utils/date-utils'; +import { formatFileSize } from '@/utils/file-utils'; + +interface PlayerReplaysListProps { + playerId: string; +} + +const isRawReplayId = (replayReference: string) => ( + !replayReference.includes('://') + && !replayReference.includes('/') + && !replayReference.includes('?') + && !replayReference.includes('#') +); + +const getReplayIdFromReference = (replayUrl?: string) => { + const replayReference = replayUrl?.trim(); + if (!replayReference) { + return ''; + } + + if (isRawReplayId(replayReference)) { + return replayReference; + } + + try { + const parsedReplayUrl = new URL(replayReference, window.location.origin); + return parsedReplayUrl.searchParams.get('id') || ''; + } catch { + return ''; + } +}; + +const getReplayId = (replay: { replayId?: string; replayUrl?: string; matchSource?: string }) => { + if (replay.matchSource === 'TICKET_FALLBACK') { + return getReplayIdFromReference(replay.replayUrl) || replay.replayId || ''; + } + + return replay.replayId || getReplayIdFromReference(replay.replayUrl); +}; + +const PlayerReplaysList = ({ playerId }: PlayerReplaysListProps) => { + const { t } = useTranslation(); + const [, navigate] = useLocation(); + const { data: replays, isLoading, error } = usePlayerReplays(playerId); + + if (isLoading) { + return ( +
+ + {t('player.loadingReplays')} +
+ ); + } + + if (error) { + return ( +
+ + {t('player.replaysLoadFailed')} +
+ ); + } + + if (!replays || replays.length === 0) { + return ( +
+

{t('player.noReplays')}

+
+ ); + } + + return ( +
+ {replays.map((replay) => { + const replayId = getReplayId(replay); + const canOpenReplay = Boolean(replayId) + && (replay.matchSource === 'TICKET_FALLBACK' || replay.status === 'COMPLETE'); + + return ( +
+
+
+
+ + + {replay.targetName || replay.targetUuid || t('player.unknownPlayer')} + + {replay.status && ( + + {replay.status} + + )} + {replay.matchSource && ( + + {replay.matchSource} + + )} +
+
+ {t('player.replayCreated')}: {replay.createdAt ? formatDateWithTime(replay.createdAt) : t('common.unknown')} + {t('player.replayMcVersion')}: {replay.mcVersion || t('common.unknown')} + {t('player.replaySize')}: {typeof replay.fileSize === 'number' ? formatFileSize(replay.fileSize) : t('common.unknown')} + {t('player.replayId')}: {replayId || t('common.unknown')} +
+
+ +
+
+ ); + })} +
+ ); +}; + +export default PlayerReplaysList; diff --git a/client/src/components/settings/AccountSettings.tsx b/client/src/components/settings/AccountSettings.tsx index 4cd1996..849a488 100644 --- a/client/src/components/settings/AccountSettings.tsx +++ b/client/src/components/settings/AccountSettings.tsx @@ -51,30 +51,36 @@ function isMobileUserAgent(ua: string): boolean { return /Android|iPhone|iPad|Mobile/.test(ua); } -function SessionsSection({ onSignOutAll }: { onSignOutAll: () => void }) { +function SessionsSection({ onSignOut }: { onSignOut: () => void }) { const { t } = useTranslation(); - const { toast } = useToast(); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); const [signingOut, setSigningOut] = useState(false); useEffect(() => { apiFetch('/v1/panel/auth/sessions') - .then(r => r.ok ? r.json() : []) - .then(setSessions) - .catch(() => setSessions([])) + .then(r => { + if (!r.ok) throw new Error('Failed to load sessions'); + return r.json(); + }) + .then((data) => { + setSessions(Array.isArray(data?.sessions) ? data.sessions : []); + setLoadError(false); + }) + .catch(() => { + setSessions([]); + setLoadError(true); + }) .finally(() => setLoading(false)); }, []); - const handleSignOutAll = async () => { + // The backend logout endpoint only invalidates the current browser's session, + // so this button signs out this device only. onSignOut (useAuth().logout) + // already POSTs /v1/panel/auth/logout, so we must NOT call it again here. + const handleSignOut = () => { setSigningOut(true); - try { - await apiFetch('/v1/panel/auth/logout', { method: 'POST' }); - onSignOutAll(); - } catch { - toast({ title: t('common.error'), variant: 'destructive' }); - setSigningOut(false); - } + onSignOut(); }; return ( @@ -86,6 +92,8 @@ function SessionsSection({ onSignOutAll }: { onSignOutAll: () => void }) { {loading ? (

Loading…

+ ) : loadError ? ( +

Failed to load sessions.

) : sessions.length === 0 ? (

No active sessions.

) : ( @@ -121,11 +129,11 @@ function SessionsSection({ onSignOutAll }: { onSignOutAll: () => void }) {
); @@ -348,7 +356,7 @@ const AccountSettings = ({ {/* MIDDLE: passkeys + sessions */}
- +
{/* RIGHT: small offset column aligned with sign-out button */} diff --git a/client/src/components/settings/BillingSettings.tsx b/client/src/components/settings/BillingSettings.tsx index bd19266..1f5cdea 100644 --- a/client/src/components/settings/BillingSettings.tsx +++ b/client/src/components/settings/BillingSettings.tsx @@ -35,6 +35,7 @@ import { StatusBanner } from '@modl-gg/shared-web/components/ui/status-banner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@modl-gg/shared-web/components/ui/alert-dialog'; import { Slider } from '@modl-gg/shared-web/components/ui/slider'; import { Label } from '@modl-gg/shared-web/components/ui/label'; +import { StatusBadge } from '@/components/ui/status-badge'; // Initialize Stripe lazily - only when a valid key is present let stripePromise: ReturnType | null = null; @@ -102,6 +103,389 @@ const plans: Plan[] = [ } ]; +// Hoisted to module scope so their component identity is stable across renders +// of BillingSettings. Previously these were declared inside the BillingSettings +// body, so every parent re-render (billing-status refetch, toast, etc.) created +// a new component type and remounted the subtree — resetting the overage slider +// state mid-edit and dropping input focus. + +interface PremiumBillingViewProps { + billingStatus: any; + t: (key: string, options?: any) => string; + toast: ReturnType['toast']; + queryClient: ReturnType; + isLoading: boolean; + handleCreatePortalSession: () => void; + handleCancelSubscription: () => void; + handleResubscribe: () => void; + cancelSubscriptionMutation: ReturnType; + resubscribeMutation: ReturnType; + getSubscriptionStatusBadge: () => React.ReactNode; +} + +const PremiumBillingView: React.FC = ({ + billingStatus, + t, + toast, + queryClient, + isLoading, + handleCreatePortalSession, + handleCancelSubscription, + handleResubscribe, + cancelSubscriptionMutation, + resubscribeMutation, + getSubscriptionStatusBadge, +}) => { + const { + currentPeriodEnd, + maxStorageLimitBytes, + maxAiOverageRequests, + } = billingStatus || {}; + const normalizedStatus: SubscriptionStatus = normalizeSubscriptionStatus(billingStatus?.subscriptionStatus); + const [storageOverageGB, setStorageOverageGB] = useState( + maxStorageLimitBytes ? Math.max(0, Math.round(maxStorageLimitBytes / (1024 * 1024 * 1024)) - 200) : 0 + ); + const [aiOverageRequests, setAiOverageRequests] = useState(maxAiOverageRequests ?? 0); + const [savingOverageLimits, setSavingOverageLimits] = useState(false); + const hasPaymentIssue = normalizedStatus === 'PAST_DUE' || normalizedStatus === 'UNPAID'; + + useEffect(() => { + if (maxStorageLimitBytes) { + setStorageOverageGB(Math.max(0, Math.round(maxStorageLimitBytes / (1024 * 1024 * 1024)) - 200)); + } + }, [maxStorageLimitBytes]); + + useEffect(() => { + setAiOverageRequests(maxAiOverageRequests ?? 0); + }, [maxAiOverageRequests]); + + const handleSaveOverageLimits = async () => { + setSavingOverageLimits(true); + try { + const response = await apiFetch('/v1/panel/billing/overage-limits', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ maxStorageOverageGB: storageOverageGB, maxAiOverageRequests: aiOverageRequests }), + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to update overage limits'); + } + toast({ + title: t('settings.billing.overageLimitsUpdated'), + description: t('settings.billing.overageLimitsUpdatedDesc', { storageGB: storageOverageGB, aiRequests: aiOverageRequests }), + }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/status'] }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/usage'] }); + } catch (error: any) { + toast({ + title: t('toast.error'), + description: error.message || t('settings.billing.overageLimitsFailed'), + variant: 'destructive', + }); + } finally { + setSavingOverageLimits(false); + } + }; + + return ( +
+ {hasPaymentIssue && ( + + + {isLoading ? t('common.loading') : t('settings.billing.updatePaymentMethod')} + + } + > + {t('settings.billing.paymentIssueDesc')} + + )} + + + +
+
+ + + {t('settings.billing.premiumSubscription')} + — + $9.99/month + + + {hasPaymentIssue + ? t('settings.billing.paymentIssueStatusDesc') + : normalizedStatus === 'CANCELED' && currentPeriodEnd + ? t('settings.billing.accessEnds', { date: new Date(currentPeriodEnd).toLocaleDateString() }) + : normalizedStatus === 'CANCELED' && !currentPeriodEnd + ? t('settings.billing.subscriptionCancelledAccessEnded') + : currentPeriodEnd + ? t(normalizedStatus === 'TRIALING' ? 'settings.billing.trialEnds' : 'settings.billing.nextBilling', { date: new Date(currentPeriodEnd).toLocaleDateString() }) + : normalizedStatus === 'ACTIVE' + ? t('settings.billing.subscriptionActive') + : t('settings.billing.stripeManageDesc') + } + +
+
+ {getSubscriptionStatusBadge()} +
+
+
+ +
+ {normalizedStatus !== 'CANCELED' && ( + + )} + + {normalizedStatus === 'ACTIVE' && ( + + + + + + + {t('settings.billing.cancelPremiumTitle')} + + {t('settings.billing.cancelPremiumDesc', { date: currentPeriodEnd ? new Date(currentPeriodEnd).toLocaleDateString() : '' })} +

+ {t('settings.billing.cancelPremiumDowngrade')} +
+
+ + {t('settings.billing.keepSubscription')} + + {t('settings.billing.yesCancelSubscription')} + + +
+
+ )} + + {normalizedStatus === 'CANCELED' && ( + <> + + + + + + + + + {t('settings.billing.reactivatePremiumTitle')} + + {t('settings.billing.reactivatePremiumDesc')} + + + + {t('common.cancel')} + + {t('settings.billing.yesReactivateSubscription')} + + + + + + )} +
+ +
+
+ + {/* Usage Overage Limits */} + + + + + {t('settings.billing.usageOverageLimits')} + + + {t('settings.billing.usageOverageLimitsDesc')} + + + + {/* Storage Overage Slider */} +
+
+ + {storageOverageGB} GB +
+ setStorageOverageGB(v)} + min={0} + max={2000} + step={10} + /> +
+ {t('settings.billing.noOverage', { unit: 'GB' })} + 2,000 GB +
+

+ {t('settings.billing.storageOverageRate', { maxCost: (storageOverageGB * 0.08).toFixed(2) })} +

+
+ + {/* AI Request Overage Slider */} +
+
+ + {aiOverageRequests.toLocaleString()} {t('settings.billing.requests')} +
+ setAiOverageRequests(v)} + min={0} + max={5000} + step={100} + /> +
+ {t('settings.billing.noOverage', { unit: t('settings.billing.requests') })} + 5,000 {t('settings.billing.requests')} +
+

+ {t('settings.billing.aiOverageRate', { maxCost: (aiOverageRequests * 0.02).toFixed(2) })} +

+
+ +

+ {t('settings.billing.higherLimitsContact')} +

+ + +
+
+
+ ); +}; + +interface FreePlanViewProps { + t: (key: string, options?: any) => string; + isLoading: boolean; + handleCreateCheckoutSession: () => void; +} + +const FreePlanView: React.FC = ({ t, isLoading, handleCreateCheckoutSession }) => { + const premiumPlan = plans.find(p => p.id === 'premium'); + const premiumFeatures = premiumPlan?.features ?? []; + + return ( +
+ {/* Upgrade to Premium Card */} + + +
+
+ + + {t('settings.billing.upgradeToPremium')} + + {t('settings.billing.upgradeToPremiumDesc')} +
+
+
+ +
+ {/* Plan Details */} +
+
+
+ $9.99 +
+
{t('settings.billing.perMonth')}
+
+ + +
+ + {/* Premium Features */} +
+

{t('settings.billing.premiumFeatures')}

+
+ {premiumFeatures.map((feature, index) => ( +
+ + {feature.icon && ( +
+ {feature.icon} +
+ )} + {feature.text} +
+ ))} +
+ {premiumFeatures.length === 0 && ( +

{t('settings.billing.premiumFeaturesUnavailable')}

+ )} +
+
+
+
+
+ ); +}; + const BillingSettings = () => { const { data: billingStatus, isLoading: isBillingLoading } = useBillingStatus(); const cancelSubscriptionMutation = useCancelSubscription(); @@ -322,22 +706,22 @@ const BillingSettings = () => { // Special handling for cancelled subscriptions if (normalizedStatus === 'CANCELED') { if (!currentPeriodEnd) { - return {t('settings.billing.expired')}; + return {t('settings.billing.expired')}; } const endDate = new Date(currentPeriodEnd); const today = new Date(); if (endDate <= today) { - return {t('settings.billing.expired')}; + return {t('settings.billing.expired')}; } else { - return {t('settings.billing.cancelled')}; + return {t('settings.billing.cancelled')}; } } switch (normalizedStatus) { case 'ACTIVE': - return {t('status.active')}; + return {t('status.active')}; case 'TRIALING': - return {t('settings.billing.trial')}; + return {t('settings.billing.trial')}; case 'PAST_DUE': return {t('settings.billing.pastDue')}; default: @@ -345,421 +729,6 @@ const BillingSettings = () => { } }; - const PlanCard: React.FC<{ plan: Plan }> = ({ plan }) => { - const isCurrent = getCurrentPlan() === plan.id; - const canUpgrade = plan.id === 'premium' && getCurrentPlan() === 'free'; - - return ( - - {isCurrent && plan.id === 'premium' && ( -
- - - {t('settings.billing.currentPlan')} - -
- )} - - - {plan.name} -
- ${plan.price} - /{plan.period} -
- {plan.description} -
- - -
- {plan.features.map((feature, index) => ( -
- {feature.included ? ( - - ) : ( -
- )} - {feature.icon && ( -
- {feature.icon} -
- )} - - {feature.text} - -
- ))} -
- -
- {isCurrent ? ( - - ) : canUpgrade ? ( - - ) : ( - - )} -
- - - ); - }; - - const PremiumBillingView = () => { - const { - currentPeriodEnd, - maxStorageLimitBytes, - maxAiOverageRequests, - } = billingStatus || {}; - const normalizedStatus: SubscriptionStatus = normalizeSubscriptionStatus(billingStatus?.subscriptionStatus); - const [storageOverageGB, setStorageOverageGB] = useState( - maxStorageLimitBytes ? Math.max(0, Math.round(maxStorageLimitBytes / (1024 * 1024 * 1024)) - 200) : 0 - ); - const [aiOverageRequests, setAiOverageRequests] = useState(maxAiOverageRequests ?? 0); - const [savingOverageLimits, setSavingOverageLimits] = useState(false); - const hasPaymentIssue = normalizedStatus === 'PAST_DUE' || normalizedStatus === 'UNPAID'; - - useEffect(() => { - if (maxStorageLimitBytes) { - setStorageOverageGB(Math.max(0, Math.round(maxStorageLimitBytes / (1024 * 1024 * 1024)) - 200)); - } - }, [maxStorageLimitBytes]); - - useEffect(() => { - setAiOverageRequests(maxAiOverageRequests ?? 0); - }, [maxAiOverageRequests]); - - const handleSaveOverageLimits = async () => { - setSavingOverageLimits(true); - try { - const response = await apiFetch('/v1/panel/billing/overage-limits', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ maxStorageOverageGB: storageOverageGB, maxAiOverageRequests: aiOverageRequests }), - }); - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Failed to update overage limits'); - } - toast({ - title: t('settings.billing.overageLimitsUpdated'), - description: t('settings.billing.overageLimitsUpdatedDesc', { storageGB: storageOverageGB, aiRequests: aiOverageRequests }), - }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/status'] }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/usage'] }); - } catch (error: any) { - toast({ - title: t('toast.error'), - description: error.message || t('settings.billing.overageLimitsFailed'), - variant: 'destructive', - }); - } finally { - setSavingOverageLimits(false); - } - }; - - return ( -
- {hasPaymentIssue && ( - - - {isLoading ? t('common.loading') : t('settings.billing.updatePaymentMethod')} - - } - > - {t('settings.billing.paymentIssueDesc')} - - )} - - - -
-
- - - {t('settings.billing.premiumSubscription')} - — - $9.99/month - - - {hasPaymentIssue - ? t('settings.billing.paymentIssueStatusDesc') - : normalizedStatus === 'CANCELED' && currentPeriodEnd - ? t('settings.billing.accessEnds', { date: new Date(currentPeriodEnd).toLocaleDateString() }) - : normalizedStatus === 'CANCELED' && !currentPeriodEnd - ? t('settings.billing.subscriptionCancelledAccessEnded') - : currentPeriodEnd - ? t(normalizedStatus === 'TRIALING' ? 'settings.billing.trialEnds' : 'settings.billing.nextBilling', { date: new Date(currentPeriodEnd).toLocaleDateString() }) - : normalizedStatus === 'ACTIVE' - ? t('settings.billing.subscriptionActive') - : t('settings.billing.stripeManageDesc') - } - -
-
- {getSubscriptionStatusBadge()} -
-
-
- -
- {normalizedStatus !== 'CANCELED' && ( - - )} - - {normalizedStatus === 'ACTIVE' && ( - - - - - - - {t('settings.billing.cancelPremiumTitle')} - - {t('settings.billing.cancelPremiumDesc', { date: currentPeriodEnd ? new Date(currentPeriodEnd).toLocaleDateString() : '' })} -

- {t('settings.billing.cancelPremiumDowngrade')} -
-
- - {t('settings.billing.keepSubscription')} - - {t('settings.billing.yesCancelSubscription')} - - -
-
- )} - - {normalizedStatus === 'CANCELED' && ( - <> - - - - - - - - - {t('settings.billing.reactivatePremiumTitle')} - - {t('settings.billing.reactivatePremiumDesc')} - - - - {t('common.cancel')} - - {t('settings.billing.yesReactivateSubscription')} - - - - - - )} -
- -
-
- - {/* Usage Overage Limits */} - - - - - {t('settings.billing.usageOverageLimits')} - - - {t('settings.billing.usageOverageLimitsDesc')} - - - - {/* Storage Overage Slider */} -
-
- - {storageOverageGB} GB -
- setStorageOverageGB(v)} - min={0} - max={2000} - step={10} - /> -
- {t('settings.billing.noOverage', { unit: 'GB' })} - 2,000 GB -
-

- {t('settings.billing.storageOverageRate', { maxCost: (storageOverageGB * 0.08).toFixed(2) })} -

-
- - {/* AI Request Overage Slider */} -
-
- - {aiOverageRequests.toLocaleString()} {t('settings.billing.requests')} -
- setAiOverageRequests(v)} - min={0} - max={5000} - step={100} - /> -
- {t('settings.billing.noOverage', { unit: t('settings.billing.requests') })} - 5,000 {t('settings.billing.requests')} -
-

- {t('settings.billing.aiOverageRate', { maxCost: (aiOverageRequests * 0.02).toFixed(2) })} -

-
- -

- {t('settings.billing.higherLimitsContact')} -

- - -
-
-
- ); - }; - - const FreePlanView = () => { - const premiumPlan = plans.find(p => p.id === 'premium'); - const premiumFeatures = premiumPlan?.features ?? []; - - return ( -
- {/* Upgrade to Premium Card */} - - -
-
- - - {t('settings.billing.upgradeToPremium')} - - {t('settings.billing.upgradeToPremiumDesc')} -
-
-
- -
- {/* Plan Details */} -
-
-
- $9.99 -
-
{t('settings.billing.perMonth')}
-
- - -
- - {/* Premium Features */} -
-

{t('settings.billing.premiumFeatures')}

-
- {premiumFeatures.map((feature, index) => ( -
- - {feature.icon && ( -
- {feature.icon} -
- )} - {feature.text} -
- ))} -
- {premiumFeatures.length === 0 && ( -

{t('settings.billing.premiumFeaturesUnavailable')}

- )} -
-
-
-
-
- ); - }; - if (isBillingLoading) { return (
@@ -780,15 +749,31 @@ const BillingSettings = () => { } const renderBillingContent = () => { - if (isPremiumUser()) { - return ; - } - - if (hasActiveSubscription()) { - return ; + if (isPremiumUser() || hasActiveSubscription()) { + return ( + + ); } - - return ; + + return ( + + ); }; return ( diff --git a/client/src/components/settings/DomainSettings.tsx b/client/src/components/settings/DomainSettings.tsx index 46aaf86..d7ddc6f 100644 --- a/client/src/components/settings/DomainSettings.tsx +++ b/client/src/components/settings/DomainSettings.tsx @@ -6,8 +6,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@modl import { Input } from '@modl-gg/shared-web/components/ui/input'; import { Label } from '@modl-gg/shared-web/components/ui/label'; import { StatusBanner } from '@modl-gg/shared-web/components/ui/status-banner'; -import { Badge } from '@modl-gg/shared-web/components/ui/badge'; import { Separator } from '@modl-gg/shared-web/components/ui/separator'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@modl-gg/shared-web/components/ui/alert-dialog'; import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import { apiFetch, getCurrentDomain } from '@/lib/api'; import { useAuth } from '@/hooks/use-auth'; @@ -278,13 +279,13 @@ const DomainSettings: React.FC = () => { }); }; - const getStatusColor = (status: string) => { + const getStatusIntent = (status: string): 'success' | 'warning' | 'info' | 'destructive' | 'neutral' => { switch (status) { - case 'active': return 'bg-green-500'; - case 'pending': return 'bg-yellow-500'; - case 'verifying': return 'bg-blue-500'; - case 'error': return 'bg-red-500'; - default: return 'bg-gray-500'; + case 'active': return 'success'; + case 'pending': return 'warning'; + case 'verifying': return 'info'; + case 'error': return 'destructive'; + default: return 'neutral'; } }; @@ -392,9 +393,9 @@ const DomainSettings: React.FC = () => {
- + {getStatusText(domainStatus.status)} - + {domainStatus.domain}
@@ -421,14 +422,34 @@ const DomainSettings: React.FC = () => { )} - + + + + + + + {t('settings.domain.removeDomainTitle', 'Remove custom domain?')} + + {t('settings.domain.removeDomainConfirm', "Traffic to this domain will stop working immediately. You'll need to update your DNS / panel config to restore access.")} + + + + {t('common.cancel')} + + {t('common.remove')} + + + +
diff --git a/client/src/components/settings/EmbedTemplateEditor.tsx b/client/src/components/settings/EmbedTemplateEditor.tsx index cd4bb9c..264b3ee 100644 --- a/client/src/components/settings/EmbedTemplateEditor.tsx +++ b/client/src/components/settings/EmbedTemplateEditor.tsx @@ -240,10 +240,10 @@ const EmbedTemplateEditor: React.FC = ({ while (i < fields.length) { const currentField = fields[i]; - if (currentField.inline && i < fields.length - 1 && fields[i + 1].inline) { - // Two inline fields side by side + if (currentField.inline && i < fields.length - 2 && fields[i + 1].inline && fields[i + 2].inline) { + // Three inline fields in a row (test most-specific case first) result.push( -
+
{replaceVariablesForPreview(currentField.name)} @@ -260,13 +260,21 @@ const EmbedTemplateEditor: React.FC = ({ {replaceVariablesForPreview(fields[i + 1].value)}
+
+
+ {replaceVariablesForPreview(fields[i + 2].name)} +
+
+ {replaceVariablesForPreview(fields[i + 2].value)} +
+
); - i += 2; // Skip next field as we've already processed it - } else if (currentField.inline && i < fields.length - 1 && fields[i + 1].inline && i < fields.length - 2 && fields[i + 2].inline) { - // Three inline fields in a row + i += 3; // Skip next two fields + } else if (currentField.inline && i < fields.length - 1 && fields[i + 1].inline) { + // Two inline fields side by side result.push( -
+
{replaceVariablesForPreview(currentField.name)} @@ -283,17 +291,9 @@ const EmbedTemplateEditor: React.FC = ({ {replaceVariablesForPreview(fields[i + 1].value)}
-
-
- {replaceVariablesForPreview(fields[i + 2].name)} -
-
- {replaceVariablesForPreview(fields[i + 2].value)} -
-
); - i += 3; // Skip next two fields + i += 2; // Skip next field as we've already processed it } else { // Single field (either non-inline or single inline) result.push( diff --git a/client/src/components/settings/GeneralSettings.tsx b/client/src/components/settings/GeneralSettings.tsx index f0d0c5d..09d3462 100644 --- a/client/src/components/settings/GeneralSettings.tsx +++ b/client/src/components/settings/GeneralSettings.tsx @@ -6,6 +6,7 @@ import { Input } from '@modl-gg/shared-web/components/ui/input'; import { Label } from '@modl-gg/shared-web/components/ui/label'; import { Separator } from '@modl-gg/shared-web/components/ui/separator'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@modl-gg/shared-web/components/ui/collapsible'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@modl-gg/shared-web/components/ui/alert-dialog'; import { useAuth } from '@/hooks/use-auth'; import { usePermissions } from '@/hooks/use-permissions'; import BillingSettings from './BillingSettings'; @@ -261,10 +262,22 @@ const GeneralSettings = ({ {showApiKey ? (fullApiKey || apiKey) : maskApiKey(apiKey)} - -
@@ -272,14 +285,56 @@ const GeneralSettings = ({
- - + + + + + + + {t('settings.general.regenerateApiKeyTitle', 'Regenerate API key?')} + + {t('settings.general.regenerateApiKeyConfirm', 'The old key will stop working immediately and any Minecraft servers using it will lose access until updated.')} + + + + {t('common.cancel')} + + {t('settings.general.regenerate')} + + + + + + + + + + + {t('settings.general.revokeApiKeyTitle', 'Revoke API key?')} + + {t('settings.general.revokeApiKeyConfirm', 'This will immediately invalidate the key. Minecraft servers using this key will lose access. This cannot be undone.')} + + + + {t('common.cancel')} + + {t('settings.general.revoke')} + + + +
) : ( diff --git a/client/src/components/settings/HomepageCardSettings.tsx b/client/src/components/settings/HomepageCardSettings.tsx index 672be9b..820dde5 100644 --- a/client/src/components/settings/HomepageCardSettings.tsx +++ b/client/src/components/settings/HomepageCardSettings.tsx @@ -10,50 +10,52 @@ import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import { queryClient } from '@/lib/queryClient'; import { apiFetch } from '@/lib/api'; import { useQuery, useMutation } from '@tanstack/react-query'; -import { Plus, Edit, Trash2, GripVertical, Eye, EyeOff } from 'lucide-react'; +import { + Plus, Edit, Trash2, GripVertical, Eye, EyeOff, + Shield, UserPlus, MessageCircle, Mail, Phone, Scale, + Users, User, UserCheck, UserX, Crown, Award, + BookOpen, Book, FileText, ScrollText, Newspaper, + Library, GraduationCap, HelpCircle, Info, AlertCircle, + ExternalLink, Link as LinkIcon, ArrowRight, ChevronRight, Home, + Search, Download, Upload, Share, + MessageSquare, Send, Inbox, Bell, Megaphone, + Radio, Headphones, Mic, Video, Calendar, + Gamepad2, Zap, Server, Globe, Wifi, Signal, + Activity, BarChart, TrendingUp, Target, Trophy, + Settings, Wrench, Cog, Sliders, Filter, + Lock, Unlock, Key, ShieldCheck, ShieldAlert, + Minus, Check, X, AlertTriangle, CheckCircle, + XCircle, Clock, Timer, Pause, Play, + CreditCard, DollarSign, Gift, Star, Heart, ThumbsUp, + Flag, Map, Compass, Navigation, Bookmark, Tag, +} from 'lucide-react'; import { Switch } from '@modl-gg/shared-web/components/ui/switch'; import { Label } from '@modl-gg/shared-web/components/ui/label'; -import * as LucideIcons from 'lucide-react'; import { DndProvider, useDrag, useDrop, DropTargetMonitor } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; -// Get list of curated icons suitable for homepage cards -const getAvailableIcons = () => { - return [ - // Common actions - 'Shield', 'UserPlus', 'MessageCircle', 'Mail', 'Phone', 'Scale', - 'Users', 'User', 'UserCheck', 'UserX', 'Crown', 'Award', - - // Content & knowledge - 'BookOpen', 'Book', 'FileText', 'File', 'ScrollText', 'Newspaper', - 'Library', 'GraduationCap', 'HelpCircle', 'Info', 'AlertCircle', - - // Navigation & links - 'ExternalLink', 'Link', 'ArrowRight', 'ChevronRight', 'Home', - 'Search', 'Eye', 'Download', 'Upload', 'Share', - - // Communication - 'MessageSquare', 'Send', 'Inbox', 'Bell', 'Megaphone', - 'Radio', 'Headphones', 'Mic', 'Video', 'Calendar', - - // Games & servers - 'Gamepad2', 'Zap', 'Server', 'Globe', 'Wifi', 'Signal', - 'Activity', 'BarChart', 'TrendingUp', 'Target', 'Trophy', - - // Settings & tools - 'Settings', 'Tool', 'Wrench', 'Cog', 'Sliders', 'Filter', - 'Lock', 'Unlock', 'Key', 'ShieldCheck', 'ShieldAlert', - - // Actions & status - 'Plus', 'Minus', 'Check', 'X', 'AlertTriangle', 'CheckCircle', - 'XCircle', 'Clock', 'Timer', 'Pause', 'Play', 'Stop', - - // Commerce & misc - 'CreditCard', 'DollarSign', 'Gift', 'Star', 'Heart', 'ThumbsUp', - 'Flag', 'Map', 'Compass', 'Navigation', 'Bookmark', 'Tag' - ].sort(); +// Allowlist of icons selectable for homepage cards; keep in sync with HomePage.tsx ICONS map. +const ICONS: Record> = { + Shield, UserPlus, MessageCircle, Mail, Phone, Scale, + Users, User, UserCheck, UserX, Crown, Award, + BookOpen, Book, FileText, ScrollText, Newspaper, + Library, GraduationCap, HelpCircle, Info, AlertCircle, + ExternalLink, Link: LinkIcon, ArrowRight, ChevronRight, Home, + Search, Eye, Download, Upload, Share, + MessageSquare, Send, Inbox, Bell, Megaphone, + Radio, Headphones, Mic, Video, Calendar, + Gamepad2, Zap, Server, Globe, Wifi, Signal, + Activity, BarChart, TrendingUp, Target, Trophy, + Settings, Wrench, Cog, Sliders, Filter, + Lock, Unlock, Key, ShieldCheck, ShieldAlert, + Plus, Minus, Check, X, AlertTriangle, CheckCircle, + XCircle, Clock, Timer, Pause, Play, + CreditCard, DollarSign, Gift, Star, Heart, ThumbsUp, + Flag, Map, Compass, Navigation, Bookmark, Tag, }; +const getAvailableIcons = () => Object.keys(ICONS).sort(); + interface HomepageCard { id: string; title: string; @@ -92,7 +94,8 @@ const fetchHomepageCards = async (): Promise => { if (!response.ok) { throw new Error('Failed to fetch homepage cards'); } - return response.json(); + const data = await response.json(); + return Array.isArray(data.cards) ? data.cards : []; }; const fetchCategories = async (): Promise => { @@ -104,7 +107,8 @@ const fetchCategories = async (): Promise => { if (!response.ok) { throw new Error('Failed to fetch categories'); } - return response.json(); + const data = await response.json(); + return Array.isArray(data.categories) ? data.categories : []; }; const ITEM_TYPE_HOMEPAGE_CARD = 'homepage-card'; @@ -423,7 +427,7 @@ const HomepageCardSettings: React.FC = () => { }; const IconPreview = ({ iconName, color }: { iconName: string; color?: string }) => { - const IconComponent = (LucideIcons as any)[iconName] || LucideIcons.BookOpen; + const IconComponent = ICONS[iconName] || BookOpen; return ; }; diff --git a/client/src/components/settings/InviteStaffModal.tsx b/client/src/components/settings/InviteStaffModal.tsx index 5e15b9a..059f550 100644 --- a/client/src/components/settings/InviteStaffModal.tsx +++ b/client/src/components/settings/InviteStaffModal.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; +import { z } from 'zod'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { Dialog, diff --git a/client/src/components/settings/KnowledgebaseSettings.tsx b/client/src/components/settings/KnowledgebaseSettings.tsx index c17d0e5..e614706 100644 --- a/client/src/components/settings/KnowledgebaseSettings.tsx +++ b/client/src/components/settings/KnowledgebaseSettings.tsx @@ -37,7 +37,7 @@ const fetchCategories = async (): Promise => { throw new Error('Failed to fetch categories'); } const data = await response.json(); - return Array.isArray(data) ? data.map(normalizeCategory) : []; + return Array.isArray(data.categories) ? data.categories.map(normalizeCategory) : []; }; const ItemTypes = { CATEGORY: 'category', @@ -95,7 +95,9 @@ const CategoryItem: React.FC = ({ useEffect(() => { if (category.articles) { - setDisplayedCategoryArticles(category.articles.sort((a,b) => a.ordinal - b.ordinal)); + // Copy before sorting: Array.prototype.sort mutates in place, and + // category.articles is owned by the react-query cache. + setDisplayedCategoryArticles([...category.articles].sort((a,b) => a.ordinal - b.ordinal)); } }, [category.articles]); @@ -188,9 +190,9 @@ const CategoryItem: React.FC = ({ ) : ( - + )} - +
@@ -339,7 +341,9 @@ const KnowledgebaseSettings: React.FC = () => { useEffect(() => { if (categories) { - setDisplayedCategories(categories.sort((a, b) => a.ordinal - b.ordinal)); + // Copy before sorting: Array.prototype.sort mutates in place, and + // categories is the array held in the react-query cache. + setDisplayedCategories([...categories].sort((a, b) => a.ordinal - b.ordinal)); } }, [categories]); diff --git a/client/src/components/settings/MigrationTool.tsx b/client/src/components/settings/MigrationTool.tsx index 470b8c0..e70c18c 100644 --- a/client/src/components/settings/MigrationTool.tsx +++ b/client/src/components/settings/MigrationTool.tsx @@ -45,7 +45,7 @@ const MigrationTool: React.FC = () => { } return undefined; - }, [currentMigration?.id, currentMigration?.status]); + }, [currentMigration?.taskId, currentMigration?.status]); const handleStartMigration = async () => { if (!selectedType || onCooldown || isActive) return; @@ -76,15 +76,15 @@ const MigrationTool: React.FC = () => { switch (status) { case 'idle': case 'building_json': - return ; + return ; case 'uploading_json': - return ; + return ; case 'processing_data': - return ; + return ; case 'completed': - return ; + return ; case 'failed': - return ; + return ; default: return ; } @@ -103,17 +103,27 @@ const MigrationTool: React.FC = () => { case 'completed': return t('settings.migration.statusCompleted'); case 'failed': - return t('settings.migration.statusFailed', { type: currentMigration.migrationType }); + return t('settings.migration.statusFailed', { type: currentMigration?.type }); default: return t('settings.migration.statusUnknown'); } }; const getProgressPercentage = (status: string, progress?: any) => { - if (progress?.totalRecords && progress?.recordsProcessed) { - return Math.round((progress.recordsProcessed / progress.totalRecords) * 100); + // A completed migration is always 100%, regardless of how many records + // were skipped vs processed. + if (status === 'completed') { + return 100; } - + + if (progress?.totalRecords) { + // Skipped records (duplicates/invalid) still represent progress through + // the source set, so count them alongside processed records; otherwise a + // run with many skips would stall the bar below 100%. + const handled = (progress.recordsProcessed ?? 0) + (progress.recordsSkipped ?? 0); + return Math.min(100, Math.round((handled / progress.totalRecords) * 100)); + } + switch (status) { case 'idle': return 5; @@ -136,11 +146,6 @@ const MigrationTool: React.FC = () => { return `${hours}h ${minutes}m`; }; - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleString(); - }; - if (statusLoading) { return (
@@ -164,7 +169,7 @@ const MigrationTool: React.FC = () => { {/* Active Migration Progress */} {isActive && currentMigration && ( - +
@@ -172,7 +177,7 @@ const MigrationTool: React.FC = () => {

{getStatusText(currentMigration.status)}

- {t('settings.migration.migratingFrom', { type: currentMigration.migrationType })} + {t('settings.migration.migratingFrom', { type: currentMigration.type })}

@@ -210,7 +215,7 @@ const MigrationTool: React.FC = () => { {currentMigration.progress.totalRecords && ` / ${currentMigration.progress.totalRecords}`} {currentMigration.progress.recordsSkipped > 0 && ( - + {t('settings.migration.skipped', { count: currentMigration.progress.recordsSkipped })} )} @@ -229,7 +234,8 @@ const MigrationTool: React.FC = () => { )} {showCompletedAlert && lastCompletedMigration && (() => { - const isCancelled = lastCompletedMigration.error?.toLowerCase().includes('cancelled'); + const lowerError = lastCompletedMigration.error?.toLowerCase() ?? ''; + const isCancelled = lowerError.includes('cancelled') || lowerError.includes('canceled'); const isSuccess = lastCompletedMigration.status === 'completed'; const variant = isSuccess ? 'success' : (isCancelled ? 'warning' : 'error'); @@ -237,9 +243,9 @@ const MigrationTool: React.FC = () => { + ) : isCancelled ? ( - + ) : undefined} > {isSuccess ? ( @@ -265,7 +271,7 @@ const MigrationTool: React.FC = () => { {onCooldown && !isActive && ( } + icon={} > {t('settings.migration.cooldownActive', { time: formatCooldownTime(cooldownRemainingMs) })} @@ -313,53 +319,6 @@ const MigrationTool: React.FC = () => {
)} - {/* Migration History */} - {migrationStatus?.history && migrationStatus.history.length > 0 && ( -
-
{t('settings.migration.recentMigrations')}
-
- {migrationStatus.history.slice(0, 5).map((entry: any, index: number) => ( -
-
- {entry.status === 'completed' ? ( - - ) : ( - - )} -
-

- {entry.migrationType.toUpperCase()} -

-

- {formatDate(entry.completedAt)} -

-
-
-
-

- {t('settings.migration.recordsCount', { count: entry.recordsProcessed })} -

- {entry.recordsSkipped > 0 && ( -

- {t('settings.migration.skipped', { count: entry.recordsSkipped })} -

- )} -
-
- ))} -
-
- )} - - {/* Last Migration Timestamp */} - {migrationStatus?.lastMigrationTimestamp && ( -

- {t('settings.migration.lastSuccessfulMigration', { date: formatDate(migrationStatus.lastMigrationTimestamp) })} -

- )}
); }; diff --git a/client/src/components/settings/PasskeySettings.tsx b/client/src/components/settings/PasskeySettings.tsx index e26c12e..55cc617 100644 --- a/client/src/components/settings/PasskeySettings.tsx +++ b/client/src/components/settings/PasskeySettings.tsx @@ -64,7 +64,8 @@ const PasskeySettings = () => { try { const res = await passkeyFetch('/v1/panel/auth/webauthn/credentials'); if (res.ok) { - setCredentials(await res.json()); + const data = await res.json(); + setCredentials(Array.isArray(data.credentials) ? data.credentials : []); } } catch { // silently fail @@ -316,7 +317,7 @@ const PasskeySettings = () => { {t('common.cancel')} - {t('common.remove')} + {t('common.remove')} diff --git a/client/src/components/settings/ProfileSettings.tsx b/client/src/components/settings/ProfileSettings.tsx index 4019cd1..8576ad5 100644 --- a/client/src/components/settings/ProfileSettings.tsx +++ b/client/src/components/settings/ProfileSettings.tsx @@ -10,7 +10,7 @@ import { apiFetch } from '@/lib/api'; const ProfileSettings = () => { const { t } = useTranslation(); - const { user } = useAuth(); + const { user, refreshUser } = useAuth(); const { toast } = useToast(); const [profileUsername, setProfileUsername] = useState(''); @@ -40,13 +40,12 @@ const ProfileSettings = () => { title: t('settings.account.profileUpdated'), description: t('settings.account.profileUpdatedDesc') }); - // Refresh the page to update the user context - setTimeout(() => { - window.location.reload(); - }, 1000); + // Refresh the user context without a full SPA reload. + await refreshUser(); } else { - const errorData = await response.json(); - throw new Error(errorData.message || t('settings.account.updateProfileFailed')); + // Backend /v1 panel errors use ErrorResponseDTO { status, error }. + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || errorData.message || t('settings.account.updateProfileFailed')); } } catch (error) { toast({ diff --git a/client/src/components/settings/PunishmentSettings.tsx b/client/src/components/settings/PunishmentSettings.tsx index 727e172..c631c97 100644 --- a/client/src/components/settings/PunishmentSettings.tsx +++ b/client/src/components/settings/PunishmentSettings.tsx @@ -119,7 +119,9 @@ const PunishmentSettings = ({ ...prev, gameplay: { ...prev.gameplay, - medium: values[0] + medium: values[0], + // Keep habitual strictly above medium so offense-level ordering stays valid. + habitual: Math.max(prev.gameplay.habitual, values[0] + 1) } }))} /> @@ -190,7 +192,9 @@ const PunishmentSettings = ({ ...prev, social: { ...prev.social, - medium: values[0] + medium: values[0], + // Keep habitual strictly above medium so offense-level ordering stays valid. + habitual: Math.max(prev.social.habitual, values[0] + 1) } }))} /> diff --git a/client/src/components/settings/StaffManagementPanel.tsx b/client/src/components/settings/StaffManagementPanel.tsx index 1776d01..e8fbf59 100644 --- a/client/src/components/settings/StaffManagementPanel.tsx +++ b/client/src/components/settings/StaffManagementPanel.tsx @@ -120,10 +120,10 @@ const StaffManagementPanel = () => { }); if (!response.ok) { - const errorData = await response.json().catch(() => ({ message: t('settings.staff.removeStaffFailed') })); + const errorData = await response.json().catch(() => ({})); toast({ title: t('toast.error'), - description: errorData.message || t('settings.staff.removeStaffFailed'), + description: errorData.error || errorData.message || t('settings.staff.removeStaffFailed'), variant: 'destructive', }); return; @@ -149,8 +149,8 @@ const StaffManagementPanel = () => { method: 'POST', }); if (!response.ok) { - const errorData = await response.json().catch(() => ({ message: t('settings.staff.resendInvitationFailed') })); - throw new Error(errorData.message); + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || errorData.message || t('settings.staff.resendInvitationFailed')); } toast({ title: t('toast.success'), diff --git a/client/src/components/settings/StaffRolesCard.tsx b/client/src/components/settings/StaffRolesCard.tsx index 40735fd..d0f594a 100644 --- a/client/src/components/settings/StaffRolesCard.tsx +++ b/client/src/components/settings/StaffRolesCard.tsx @@ -70,6 +70,7 @@ const DEFAULT_PERMISSIONS: Permission[] = [ { id: 'admin.audit.view.logs', name: 'View Logs', description: 'View audit trail of staff actions', category: 'admin', parentId: 'admin.audit.view' }, // Punishment permissions + { id: 'punishment.view', name: 'View Punishments', description: 'View player profiles, punishments, and linked accounts', category: 'punishment', parentId: null }, { id: 'punishment.modify', name: 'Modify Punishments', description: 'Full control over existing punishments (includes all sub-permissions)', category: 'punishment', parentId: null }, { id: 'punishment.modify.pardon', name: 'Pardon Punishments', description: 'Pardon punishments and clear associated points', category: 'punishment', parentId: 'punishment.modify' }, { id: 'punishment.modify.duration', name: 'Modify Duration', description: 'Change punishment duration', category: 'punishment', parentId: 'punishment.modify' }, @@ -265,7 +266,7 @@ const DraggableRoleCard: React.FC = ({ )} {role.name === 'Super Admin' && ( - + {t('settings.roles.highestRank')} )} @@ -276,6 +277,7 @@ const DraggableRoleCard: React.FC = ({ size="sm" onClick={() => onEditRole(role)} disabled={role.name === 'Super Admin' || (roleOrderMap.get(currentUserRole || '') ?? 999) >= getRoleOrder(role)} + aria-label={t('common.edit')} > @@ -284,6 +286,7 @@ const DraggableRoleCard: React.FC = ({ size="sm" onClick={() => onDeleteRole(role)} disabled={role.name === 'Super Admin' || (roleOrderMap.get(currentUserRole || '') ?? 999) >= getRoleOrder(role)} + aria-label={t('common.delete')} > @@ -367,10 +370,6 @@ export default function StaffRolesCard() { }); } - if (!currentUser) { - return
{t('common.loadingUser')}
; - } - // Update local roles when server data changes useEffect(() => { if (effectiveRoles.length > 0) { @@ -385,6 +384,12 @@ export default function StaffRolesCard() { } }, [effectiveRoles]); + // Guard placed AFTER all hook declarations so the hook count never changes + // between renders (Rules of Hooks). + if (!currentUser) { + return
{t('common.loadingUser')}
; + } + // Handle drag start const handleDragStart = () => { setIsDragInProgress(true); @@ -428,11 +433,41 @@ export default function StaffRolesCard() { // Filter out Super Admin from the reorder request since it should never be reordered // Super Admin should always stay at order 0, other roles start from order 1 const nonSuperAdminRoles = pendingReorder.filter(role => role.name !== 'Super Admin'); - const roleOrder = nonSuperAdminRoles.map((role, index) => ({ - id: role.id, - order: index + 1 // Start from 1 since Super Admin is always 0 - })); - + + let roleOrder: Array<{ id: string; order: number }>; + if (isSuperAdmin) { + // Super Admin has authority over every role, so it may renumber the + // entire non-super-admin hierarchy starting at 1. + roleOrder = nonSuperAdminRoles.map((role, index) => ({ + id: role.id, + order: index + 1, // Start from 1 since Super Admin is always 0 + })); + } else { + // A non-super-admin performer may only reorder roles STRICTLY below + // their own authority. The backend rejects the whole request if any + // submitted role is at/above the performer's order, so we must: + // (1) only submit roles whose order is greater than the performer's, and + // (2) assign new orders that remain strictly greater than performerOrder, + // preserving the existing higher-authority roles' positions. + const performerOrder = roleOrderMap.get(currentUser.role) ?? 999; + const editableRoles = nonSuperAdminRoles.filter(role => { + const currentOrder = role.order ?? 999; + return currentOrder > performerOrder; + }); + roleOrder = editableRoles.map((role, index) => ({ + id: role.id, + order: performerOrder + 1 + index, + })); + } + + if (roleOrder.length === 0) { + // Nothing the performer is allowed to reorder; treat as a no-op success. + setOriginalRoles([...localRoles]); + setPendingReorder(null); + setShowReorderConfirm(false); + return; + } + const response = await apiFetch('/v1/panel/roles/reorder', { method: 'POST', headers: { @@ -885,29 +920,29 @@ export default function StaffRolesCard() { {/* Delete Confirmation Dialog */} - !open && setDeleteConfirmRole(null)}> - - - {t('settings.roles.deleteRoleTitle')} - + !open && setDeleteConfirmRole(null)}> + + + {t('settings.roles.deleteRoleTitle')} + {t('settings.roles.deleteRoleConfirm', { name: deleteConfirmRole?.name })} - - - - - - - - + + + + {/* Role Reorder Confirmation Dialog */} diff --git a/client/src/components/settings/TicketSettings.tsx b/client/src/components/settings/TicketSettings.tsx index 8a14fb3..8c8087c 100644 --- a/client/src/components/settings/TicketSettings.tsx +++ b/client/src/components/settings/TicketSettings.tsx @@ -21,6 +21,7 @@ import { TicketFormField, TicketFormSection, TicketFormSettings, TicketFormsConf import { useBillingStatus } from '@/hooks/use-data'; import { useAuth } from '@/hooks/use-auth'; import { hasPremiumAccess } from '@/lib/backend-enums'; +import { StatusBadge } from '@/components/ui/status-badge'; // Label type definition interface Label { @@ -2191,10 +2192,10 @@ const TicketSettings = ({

{t('settings.tickets.aiModerationSettings')}

{!isPremiumUser() && ( - + Premium - + )}
@@ -2218,12 +2219,12 @@ const TicketSettings = ({
{!isPremiumUser() ? (
- +

{t('settings.tickets.premiumFeature')}

{t('settings.tickets.aiModerationPremiumDesc')}

- diff --git a/client/src/components/settings/UsageSettings.tsx b/client/src/components/settings/UsageSettings.tsx index b5bde95..97daf61 100644 --- a/client/src/components/settings/UsageSettings.tsx +++ b/client/src/components/settings/UsageSettings.tsx @@ -9,11 +9,14 @@ import { Progress } from '@modl-gg/shared-web/components/ui/progress'; import { Card, CardContent, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; import { Badge } from '@modl-gg/shared-web/components/ui/badge'; import { Checkbox } from '@modl-gg/shared-web/components/ui/checkbox'; +import { Switch } from '@modl-gg/shared-web/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@modl-gg/shared-web/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@modl-gg/shared-web/components/ui/table'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@modl-gg/shared-web/components/ui/alert-dialog'; import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import { formatFileSize } from '@/utils/file-utils'; +import { useReplayRetentionSettings, useUpdateReplayRetentionSettings } from '@/hooks/use-data'; +import { Notice } from '@/components/ui/notice'; interface StorageFile { id: string; @@ -94,6 +97,11 @@ interface StorageSettings { }; } +// Clamp a usage percentage to [0, 100] (and guard NaN/Infinity) so over-quota +// tenants don't render a broken Progress bar or a misleading ">100%" label. +const clampPercent = (n: number) => + Math.max(0, Math.min(100, Math.round((Number.isFinite(n) ? n : 0) * 100) / 100)); + const UsageSettings = () => { const { toast } = useToast(); const { t } = useTranslation(); @@ -114,6 +122,10 @@ const UsageSettings = () => { const [newOverageLimit, setNewOverageLimit] = useState(0); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(25); + const [replayRetentionEnabled, setReplayRetentionEnabled] = useState(true); + const [replayRetentionDays, setReplayRetentionDays] = useState(7); + const { data: replayRetentionSettings, isLoading: isLoadingReplayRetention, error: replayRetentionError } = useReplayRetentionSettings(); + const updateReplayRetentionSettings = useUpdateReplayRetentionSettings(); const DEFAULT_AI_LIMIT = 1000; useEffect(() => { @@ -121,6 +133,15 @@ const UsageSettings = () => { fetchStorageSettings(); }, []); + useEffect(() => { + if (!replayRetentionSettings?.data) { + return; + } + + setReplayRetentionEnabled(Boolean(replayRetentionSettings.data.enabled)); + setReplayRetentionDays(Number(replayRetentionSettings.data.days || 7)); + }, [replayRetentionSettings]); + const fetchStorageData = async () => { try { setLoading(true); @@ -160,20 +181,20 @@ const fetchStorageData = async () => { cdnPercentage = usageData.cdn?.percentage ?? (cdnLimit > 0 ? (cdnUsed / cdnLimit) * 100 : 0); } else { // Old format: values are already in bytes - cdnUsedBytes = usageData.usedBytes ?? 0; - cdnLimitBytes = usageData.maxBytes ?? 0; + cdnUsedBytes = Number(usageData.usedBytes ?? 0); + cdnLimitBytes = Number(usageData.maxBytes ?? 0); cdnPercentage = usageData.usedPercentage ?? (cdnLimitBytes > 0 ? (cdnUsedBytes / cdnLimitBytes) * 100 : 0); } // Transform the data to match expected format // Use byType from backend if available, otherwise fallback to putting all in "other" const byType = usageData.byType ? { - ticket: usageData.byType.ticket ?? 0, - evidence: usageData.byType.evidence ?? 0, - logs: usageData.byType.logs ?? 0, - backup: usageData.byType.backup ?? 0, - replay: usageData.byType.replay ?? 0, - other: usageData.byType.other ?? 0 + ticket: Number(usageData.byType.ticket ?? 0), + evidence: Number(usageData.byType.evidence ?? 0), + logs: Number(usageData.byType.logs ?? 0), + backup: Number(usageData.byType.backup ?? 0), + replay: Number(usageData.byType.replay ?? 0), + other: Number(usageData.byType.other ?? 0) } : { ticket: 0, evidence: 0, @@ -204,8 +225,8 @@ const fetchStorageData = async () => { overageCost: usageData.cdn?.overageCost ?? 0, isPaid: billingUsageData?.usageBillingEnabled ?? usageData.usageBillingEnabled ?? false, canUpload: cdnPercentage < 100, - usagePercentage: Math.round(cdnPercentage * 100) / 100, - baseUsagePercentage: Math.round(cdnPercentage * 100) / 100 + usagePercentage: clampPercent(cdnPercentage), + baseUsagePercentage: clampPercent(cdnPercentage) }, aiQuota: isPremium && billingUsageData?.ai ? { totalUsed: Number(billingUsageData.ai.used ?? 0), @@ -213,7 +234,7 @@ const fetchStorageData = async () => { overageUsed: Number(billingUsageData.ai.overage ?? 0), overageCost: Number(billingUsageData.ai.overageCost ?? 0), canUseAI: Number(billingUsageData.ai.used ?? 0) < Number(billingUsageData.ai.limit ?? DEFAULT_AI_LIMIT), - usagePercentage: Math.max(0, Math.min(100, Math.round(Number(billingUsageData.ai.percentage ?? 0) * 100) / 100)), + usagePercentage: clampPercent(Number(billingUsageData.ai.percentage ?? 0)), byService: { moderation: 0, ticket_analysis: 0, @@ -226,7 +247,7 @@ const fetchStorageData = async () => { overageUsed: usageData.aiQuota.overageUsed ?? 0, overageCost: usageData.aiQuota.overageCost ?? 0, canUseAI: usageData.aiQuota.canUseAI ?? false, - usagePercentage: Math.round((usageData.aiQuota.usagePercentage ?? 0) * 100) / 100, + usagePercentage: clampPercent(usageData.aiQuota.usagePercentage ?? 0), byService: usageData.aiQuota.byService ? { moderation: usageData.aiQuota.byService.moderation ?? 0, ticket_analysis: usageData.aiQuota.byService.ticket_analysis ?? 0, @@ -244,7 +265,7 @@ const fetchStorageData = async () => { overageUsed: usageData.ai.overage ?? 0, overageCost: usageData.ai.overageCost ?? 0, canUseAI: (usageData.ai.percentage ?? 0) < 100, - usagePercentage: Math.round((usageData.ai.percentage ?? 0) * 100) / 100, + usagePercentage: clampPercent(usageData.ai.percentage ?? 0), byService: { moderation: 0, ticket_analysis: 0, @@ -365,14 +386,44 @@ const fetchStorageData = async () => { } }; + const handleSaveReplayRetention = () => { + const days = Math.min(365, Math.max(1, Math.floor(Number(replayRetentionDays) || 1))); + const expectedVersion = Number(replayRetentionSettings?._meta?.version ?? 0); + + updateReplayRetentionSettings.mutate({ + expectedVersion, + enabled: replayRetentionEnabled, + days, + }, { + onSuccess: (envelope) => { + if (envelope?.data) { + setReplayRetentionEnabled(Boolean(envelope.data.enabled)); + setReplayRetentionDays(Number(envelope.data.days || days)); + } + + toast({ + title: t('toast.success'), + description: t('settings.usage.replayRetentionSaved'), + }); + }, + onError: (error: any) => { + toast({ + title: t('toast.error'), + description: error?.message || t('settings.usage.replayRetentionSaveFailed'), + variant: "destructive", + }); + }, + }); + }; + const getTypeColor = (type: string): string => { switch (type) { - case 'ticket': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'; - case 'evidence': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'; - case 'logs': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'; - case 'backup': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'; - case 'replay': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300'; - default: return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'; + case 'ticket': return 'bg-info/20 text-info border-info/30'; + case 'evidence': return 'bg-destructive/20 text-destructive border-destructive/30'; + case 'logs': return 'bg-success/20 text-success border-success/30'; + case 'backup': return 'bg-accent/20 text-accent border-accent/30'; + case 'replay': return 'bg-warning/20 text-warning border-warning/30'; + default: return 'bg-muted text-muted-foreground border-border'; } }; @@ -564,20 +615,14 @@ const fetchStorageData = async () => { {/* Free User Limit Warning */} {!storageUsage?.quota?.isPaid && storageUsage?.quota?.baseUsagePercentage >= 80 && ( -
-
- -
-

{t('settings.usage.storageLimitWarning')}

-

- {t('settings.usage.storageLimitWarningDesc', { percentage: storageUsage.quota.baseUsagePercentage, limit: storageUsage.quota.baseLimitFormatted })} -

-

- {t('settings.usage.upgradePremiumStorage')} -

-
-
-
+ +

+ {t('settings.usage.storageLimitWarningDesc', { percentage: storageUsage.quota.baseUsagePercentage, limit: storageUsage.quota.baseLimitFormatted })} +

+

+ {t('settings.usage.upgradePremiumStorage')} +

+
)} {/* Storage Overview */} @@ -786,33 +831,67 @@ const fetchStorageData = async () => { - {t('settings.usage.systemStatus')} + + + {t('settings.usage.replayRetention')} + -
-
- {t('settings.usage.totalFiles')}: - {files.length} -
-
- {t('settings.usage.selected')}: - {selectedFiles.size} -
-
- {t('settings.usage.filtered')}: - {filteredAndSortedFiles.length} +
+ {replayRetentionError && ( +
+ {t('settings.usage.replayRetentionLoadFailed')} +
+ )} + +
+
+ +

+ {t('settings.usage.replayRetentionEnabledDesc')} +

+
+
-
- {t('settings.usage.canUpload')}: - - {storageUsage.quota?.canUpload ? t('common.yes') : t('common.no')} - + +
+ + setReplayRetentionDays(Number(event.target.value))} + disabled={!replayRetentionEnabled || isLoadingReplayRetention || updateReplayRetentionSettings.isPending} + /> +

+ {replayRetentionEnabled + ? t('settings.usage.replayRetentionDaysDesc') + : t('settings.usage.replayRetentionDisabledDesc')} +

-
- {t('settings.usage.aiAvailable')}: - - {storageUsage.isPremium && storageUsage.aiQuota ? t('common.yes') : t('common.no')} - + +
+ + {replayRetentionEnabled + ? t('settings.usage.replayRetentionActive', { days: Math.max(1, Math.floor(Number(replayRetentionDays) || 1)) }) + : t('settings.usage.replayRetentionOff')} + +
@@ -976,6 +1055,7 @@ const fetchStorageData = async () => { variant="ghost" size="sm" onClick={() => window.open(file.url, '_blank')} + aria-label={t('common.download')} > @@ -983,8 +1063,9 @@ const fetchStorageData = async () => { variant="ghost" size="sm" onClick={() => handleDeleteFile(file.id)} + aria-label={t('common.delete')} > - +
@@ -1064,7 +1145,7 @@ const fetchStorageData = async () => { {t('common.cancel')} - + {t('common.delete')} @@ -1119,8 +1200,8 @@ const fetchStorageData = async () => {
)} -
-

+

+

{t('common.important')}: {t('settings.usage.overageLimitWarning')}

diff --git a/client/src/components/settings/WebhookSettings.tsx b/client/src/components/settings/WebhookSettings.tsx index a085816..5ea1855 100644 --- a/client/src/components/settings/WebhookSettings.tsx +++ b/client/src/components/settings/WebhookSettings.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Eye, EyeOff, TestTube, MessageCircle, ChevronDown, ChevronRight } from 'lucide-react'; import { Button } from '@modl-gg/shared-web/components/ui/button'; @@ -116,7 +116,10 @@ const WebhookSettings: React.FC = ({ const [showWebhookUrl, setShowWebhookUrl] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isTesting, setIsTesting] = useState(false); - const [saveTimeout, setSaveTimeout] = useState(null); + // The debounce timer id lives in a ref (not state) so rapid successive edits + // synchronously see and clear the previously scheduled save, preventing + // duplicate auto-save POSTs. + const saveTimeoutRef = useRef | null>(null); // Collapsible state for embed templates const [isNewTicketsExpanded, setIsNewTicketsExpanded] = useState(false); @@ -183,24 +186,24 @@ const WebhookSettings: React.FC = ({ // Cleanup timeout on unmount useEffect(() => { return () => { - if (saveTimeout) { - clearTimeout(saveTimeout); + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); } }; - }, [saveTimeout]); + }, []); const autoSave = async (newSettings: WebhookSettings) => { if (!hasPermission('admin.settings.modify') || !onSave) { return; } - // Clear existing timeout - if (saveTimeout) { - clearTimeout(saveTimeout); + // Clear existing timeout (ref read reflects the latest scheduled timer immediately) + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); } // Set new timeout to save after 1 second of no changes - const timeoutId = setTimeout(async () => { + saveTimeoutRef.current = setTimeout(async () => { setIsSaving(true); try { // Use panelIconUrl as default if avatar URL is empty @@ -219,8 +222,6 @@ const WebhookSettings: React.FC = ({ setIsSaving(false); } }, 1000); - - setSaveTimeout(timeoutId); }; const handleInputChange = (field: keyof WebhookSettings, value: any) => { diff --git a/client/src/components/tickets/FilterDropdown.tsx b/client/src/components/tickets/FilterDropdown.tsx index 3b92762..2677bb4 100644 --- a/client/src/components/tickets/FilterDropdown.tsx +++ b/client/src/components/tickets/FilterDropdown.tsx @@ -17,6 +17,7 @@ interface FilterDropdownProps { onChange: (selected: string[]) => void; multiSelect?: boolean; placeholder?: string; + searchable?: boolean; } export function FilterDropdown({ @@ -26,9 +27,11 @@ export function FilterDropdown({ onChange, multiSelect = false, placeholder = 'Select...', + searchable = false, }: FilterDropdownProps) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(''); const dropdownRef = useRef(null); // Close dropdown when clicking outside @@ -43,6 +46,20 @@ export function FilterDropdown({ return () => document.removeEventListener('mousedown', handleClickOutside); }, []); + // Reset the search query whenever the dropdown closes. + useEffect(() => { + if (!isOpen) setQuery(''); + }, [isOpen]); + + const normalizedQuery = query.trim().toLowerCase(); + const filteredOptions = searchable && normalizedQuery + ? options.filter( + (option) => + option.label.toLowerCase().includes(normalizedQuery) || + option.value.toLowerCase().includes(normalizedQuery), + ) + : options; + const handleSelect = (value: string) => { if (multiSelect) { if (selected.includes(value)) { @@ -77,13 +94,25 @@ export function FilterDropdown({ {isOpen && (
+ {searchable && ( +
+ setQuery(e.target.value)} + placeholder={placeholder} + autoFocus + className="w-full h-8 px-2 text-sm bg-background border border-border rounded outline-none focus:ring-1 focus:ring-primary" + /> +
+ )}
- {options.length === 0 ? ( + {filteredOptions.length === 0 ? (
{t('tickets.filter.noOptions')}
) : ( - options.map((option) => ( + filteredOptions.map((option) => (
+ ); +} diff --git a/client/src/components/ui/markdown-renderer.tsx b/client/src/components/ui/markdown-renderer.tsx index 87a34f1..5c16b6f 100644 --- a/client/src/components/ui/markdown-renderer.tsx +++ b/client/src/components/ui/markdown-renderer.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, type ReactNode } from 'react'; import ReactMarkdown from 'react-markdown'; import { cn } from '@modl-gg/shared-web/lib/utils'; import { ClickablePlayer } from './clickable-player'; @@ -81,19 +81,13 @@ const processMarkdownContent = (content: string, disableClickablePlayers = false }; const MarkdownRenderer = ({ content, className, allowHtml = false, disableClickablePlayers = false }: MarkdownRendererProps) => { - // Guard against undefined/null content - render empty for falsy values - if (content === undefined || content === null) { - return null; - } - - // For empty strings, just return an empty container - if (content === '') { - return
; - } - - const processedContent = processMarkdownContent(content, disableClickablePlayers); const { data: mediaConfig } = useMediaUploadConfig(); + const processedContent = useMemo( + () => processMarkdownContent(content ?? '', disableClickablePlayers), + [content, disableClickablePlayers] + ); + const normalizedCdnHost = useMemo(() => { const rawDomain = mediaConfig?.cdnDomain?.trim(); if (!rawDomain) return null; @@ -106,6 +100,14 @@ const MarkdownRenderer = ({ content, className, allowHtml = false, disableClicka } }, [mediaConfig?.cdnDomain]); + if (content === undefined || content === null) { + return null; + } + + if (content === '') { + return
; + } + const getTrustedMediaKind = (href?: string): 'image' | 'video' | null => { if (!href || !normalizedCdnHost) return null; @@ -127,9 +129,43 @@ const MarkdownRenderer = ({ content, className, allowHtml = false, disableClicka return null; }; - // Check if content contains structured form data (bullet points, bold labels) - const hasStructuredContent = /\*\*[^*]+\*\*:\s*\n(•[^\n]*\n?)+/.test(content); - + // Check if content contains structured form data (bullet points, bold labels). + // Detect AND render from processedContent so the structured branch consumes the + // same string as the ReactMarkdown branch (player markers + chat formatting). + const hasStructuredContent = /\*\*[^*]+\*\*:\s*\n(•[^\n]*\n?)+/.test(processedContent); + + // Render a single structured-content line, turning inline **[PLAYER:username]** + // markers (injected by processMarkdownContent) into clickable player links so + // chat-message lines inside structured content behave like the markdown branch. + const renderStructuredLine = (line: string): ReactNode => { + const tokenRegex = /\*\*\[PLAYER:([^\]]+)\]\*\*/g; + if (disableClickablePlayers || !tokenRegex.test(line)) { + // No player markers to linkify; strip any leftover bold markers for display. + return line.replace(/\*\*\[PLAYER:([^\]]+)\]\*\*/g, '$1'); + } + const parts: ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + tokenRegex.lastIndex = 0; + let key = 0; + while ((match = tokenRegex.exec(line)) !== null) { + if (match.index > lastIndex) { + parts.push(line.slice(lastIndex, match.index)); + } + const username = match[1]; + parts.push( + + {username} + + ); + lastIndex = match.index + match[0].length; + } + if (lastIndex < line.length) { + parts.push(line.slice(lastIndex)); + } + return parts; + }; + if (hasStructuredContent) { // For structured content (like appeal form data), use pre-wrap to preserve formatting return ( @@ -138,10 +174,26 @@ const MarkdownRenderer = ({ content, className, allowHtml = false, disableClicka className )}> {/* Parse and render the structured content manually */} - {content.split('\n').map((line, index) => { + {processedContent.split('\n').map((line, index) => { // Handle bold labels if (line.match(/^\*\*[^*]+\*\*:/)) { const label = line.replace(/^\*\*([^*]+)\*\*:/, '$1'); + // A label of the form [PLAYER:username] is a clickable-player marker + // injected by processMarkdownContent; render it as a player link. + const playerMatch = label.match(/^\[PLAYER:(.*)\]$/); + if (playerMatch && !disableClickablePlayers) { + const username = playerMatch[1]; + return ( +
+ + + {username} + + + : +
+ ); + } return (
{label}: @@ -152,7 +204,7 @@ const MarkdownRenderer = ({ content, className, allowHtml = false, disableClicka if (line.startsWith('• ')) { return (
- {line} + {renderStructuredLine(line)}
); } @@ -160,7 +212,7 @@ const MarkdownRenderer = ({ content, className, allowHtml = false, disableClicka if (line.trim()) { return (
- {line} + {renderStructuredLine(line)}
); } diff --git a/client/src/components/ui/notice.tsx b/client/src/components/ui/notice.tsx new file mode 100644 index 0000000..3072411 --- /dev/null +++ b/client/src/components/ui/notice.tsx @@ -0,0 +1,38 @@ +import { type ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import { AlertCircle, Info, AlertTriangle, CheckCircle2 } from 'lucide-react'; + +type Variant = 'info' | 'success' | 'warning' | 'error'; + +const styles: Record = { + info: 'bg-info/10 text-info border-info/20', + success: 'bg-success/10 text-success border-success/20', + warning: 'bg-warning/10 text-warning border-warning/20', + error: 'bg-destructive/10 text-destructive border-destructive/20', +}; + +const icons: Record = { + info: , + success: , + warning: , + error: , +}; + +export interface NoticeProps { + variant?: Variant; + title?: string; + children?: ReactNode; + className?: string; +} + +export function Notice({ variant = 'info', title, children, className }: NoticeProps) { + return ( +
+ {icons[variant]} +
+ {title &&
{title}
} + {children &&
{children}
} +
+
+ ); +} diff --git a/client/src/components/ui/page-title.tsx b/client/src/components/ui/page-title.tsx new file mode 100644 index 0000000..de1fa5f --- /dev/null +++ b/client/src/components/ui/page-title.tsx @@ -0,0 +1,14 @@ +import { type ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +export function PageTitle({ children, className }: { children: ReactNode; className?: string }) { + return

{children}

; +} + +export function SectionTitle({ children, className }: { children: ReactNode; className?: string }) { + return

{children}

; +} + +export function StatValue({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} diff --git a/client/src/components/ui/player-punishment.tsx b/client/src/components/ui/player-punishment.tsx index 902c291..f93a95b 100644 --- a/client/src/components/ui/player-punishment.tsx +++ b/client/src/components/ui/player-punishment.tsx @@ -274,14 +274,16 @@ const PlayerPunishment: React.FC = ({ // Function to search for active punishments by ID or player name const searchLinkedBan = async (query: string) => { - if (!query.trim()) { + // Backend requires q to be at least 2 characters (@Size(min=2)); avoid firing + // a request that would 400 for empty/single-character queries. + if (query.trim().length < 2) { setLinkedBanSearchResults([]); return; } try { const { getApiUrl, getCurrentDomain } = await import('@/lib/api'); - const response = await fetch(getApiUrl(`/v1/panel/punishments/search?q=${encodeURIComponent(query)}&activeOnly=true`), { + const response = await fetch(getApiUrl(`/v1/panel/players/punishments/search?q=${encodeURIComponent(query)}&activeOnly=true`), { credentials: 'include', headers: { 'X-Server-Domain': getCurrentDomain() } }); @@ -298,6 +300,22 @@ const PlayerPunishment: React.FC = ({ setLinkedBanSearchResults([]); }; + // The search API serializes PunishmentSearchResult with `typeOrdinal` (an int), + // not a human-readable `type`. Resolve the ordinal to a punishment-type name + // using the tenant's configured types so the result row shows a real label. + const resolvePunishmentTypeName = (result: any): string => { + if (result?.type) return result.type; // future-proof if server ever sends a name + const ordinal = result?.typeOrdinal; + if (ordinal === undefined || ordinal === null) return ''; + const allTypes = [ + ...(punishmentTypesByCategory.Administrative || []), + ...(punishmentTypesByCategory.Social || []), + ...(punishmentTypesByCategory.Gameplay || []), + ]; + const match = allTypes.find((type: any) => type.ordinal === ordinal || type.id === ordinal); + return match?.name ?? String(ordinal); + }; + const renderCategoryGrid = (types: any[], title: string) => (
@@ -526,7 +544,7 @@ const PlayerPunishment: React.FC = ({ >
{punishment.id}
- {punishment.playerName} - {punishment.type} - {punishment.status} + {punishment.playerName} - {resolvePunishmentTypeName(punishment)} - {punishment.status}
))} diff --git a/client/src/components/ui/status-badge.tsx b/client/src/components/ui/status-badge.tsx new file mode 100644 index 0000000..03cc051 --- /dev/null +++ b/client/src/components/ui/status-badge.tsx @@ -0,0 +1,29 @@ +import { Badge } from '@modl-gg/shared-web/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { type ReactNode } from 'react'; + +type Intent = 'info' | 'success' | 'warning' | 'destructive' | 'neutral'; + +const styles: Record = { + info: 'bg-info/20 text-info border-info/30', + success: 'bg-success/20 text-success border-success/30', + warning: 'bg-warning/20 text-warning border-warning/30', + destructive: 'bg-destructive/20 text-destructive border-destructive/30', + neutral: 'bg-muted text-muted-foreground border-border', +}; + +export function StatusBadge({ + intent = 'neutral', + children, + className, +}: { + intent?: Intent; + children: ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/client/src/components/windows/PlayerWindow.tsx b/client/src/components/windows/PlayerWindow.tsx index 895eab1..54b3522 100644 --- a/client/src/components/windows/PlayerWindow.tsx +++ b/client/src/components/windows/PlayerWindow.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Eye, TriangleAlert, History, Link2, StickyNote, Ticket, UserRound, Shield, FileText, Loader2, - ChevronDown, ChevronRight, Settings, Plus, X + ChevronDown, ChevronRight, Settings, Plus, X, Play } from 'lucide-react'; import { useLocation } from 'wouter'; import { Button } from '@modl-gg/shared-web/components/ui/button'; @@ -17,10 +17,18 @@ import { usePermissions } from '@/hooks/use-permissions'; import { toast } from '@modl-gg/shared-web/hooks/use-toast'; import PlayerPunishment, { PlayerPunishmentData } from '@/components/ui/player-punishment'; import MediaUpload from '@/components/MediaUpload'; +import PlayerReplaysList from '@/components/player-replays-list'; import { formatDateWithTime } from '@/utils/date-utils'; import { getAvatarUrl, apiFetch } from '@/lib/api'; import { formatTicketStatusLabel, normalizeTicketStatus } from '@/lib/ticket-enums'; +// Modification types that mark a punishment as pardoned. Mirrors the backend +// PunishmentModificationType.isPardon() set (MANUAL_PARDON, APPEAL_ACCEPT, +// SYSTEM_PARDON) so system/auto pardons display as "Pardoned" rather than a +// generic "Inactive". +const PARDON_MODIFICATION_TYPES = ['MANUAL_PARDON', 'APPEAL_ACCEPT', 'SYSTEM_PARDON']; +const isPardonModification = (mod: any) => PARDON_MODIFICATION_TYPES.includes(mod?.type); + // Local type definitions interface WindowPosition { x: number; @@ -1182,10 +1190,8 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind // Helper function to determine if a punishment is currently active based on expiry logic const isPunishmentCurrentlyActive = (warning: any, effectiveState: any) => { // Check if punishment is pardoned/revoked - const pardonModification = effectiveState.modifications.find((mod: any) => - mod.type === 'MANUAL_PARDON' || mod.type === 'APPEAL_ACCEPT' - ); - + const pardonModification = effectiveState.modifications.find(isPardonModification); + if (pardonModification) { return false; // Pardoned punishments are always inactive } @@ -1310,15 +1316,16 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind let effectiveExpiry = originalExpiry; let effectiveDuration = originalDuration; - // Apply modifications in chronological order - const sortedModifications = modifications.sort((a: any, b: any) => { + // Apply modifications in chronological order (copy before sorting so we don't + // mutate the react-query-owned modifications array in place). + const sortedModifications = [...modifications].sort((a: any, b: any) => { const dateA = a.date ? new Date(a.date).getTime() : 0; const dateB = b.date ? new Date(b.date).getTime() : 0; return dateA - dateB; }); for (const mod of sortedModifications) { - if (mod.type === 'MANUAL_PARDON' || mod.type === 'APPEAL_ACCEPT') { + if (isPardonModification(mod)) { effectiveActive = false; } else if (mod.type === 'MANUAL_DURATION_CHANGE' || mod.type === 'APPEAL_DURATION_CHANGE') { if (mod.effectiveDuration !== undefined) { @@ -1503,7 +1510,7 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind
- + {t('player.tabs.history')} @@ -1520,6 +1527,10 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind {t('player.tabs.tickets')} + + + {t('player.tabs.replays')} + {t('player.tabs.names')} @@ -1572,10 +1583,8 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind const isInactive = !effectiveState.effectiveActive; // Check if punishment is pardoned - const pardonModification = effectiveState.modifications.find((mod: any) => - mod.type === 'MANUAL_PARDON' || mod.type === 'APPEAL_ACCEPT' - ); - + const pardonModification = effectiveState.modifications.find(isPardonModification); + if (pardonModification) { return ( @@ -1660,17 +1669,16 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind } }; // Check if punishment is inactive/pardoned - const pardonModification = effectiveState.modifications.find((mod: any) => - mod.type === 'MANUAL_PARDON' || mod.type === 'APPEAL_ACCEPT' - ); - + const pardonModification = effectiveState.modifications.find(isPardonModification); + // Check if punishment is inactive (either pardoned or naturally expired/inactive) const isInactive = !effectiveState.effectiveActive; - + if (pardonModification) { - // Calculate time since pardoned - if (pardonModification.issued) { - const pardonDate = new Date(pardonModification.issued); + // Calculate time since pardoned. Modifications carry `date` + // (ISO from the mapper), not `issued`. + if (pardonModification.date) { + const pardonDate = new Date(pardonModification.date); if (!isNaN(pardonDate.getTime())) { const now = new Date(); const timeDiff = now.getTime() - pardonDate.getTime(); @@ -2076,7 +2084,11 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind variant="outline" size="sm" className="h-6 px-2 text-xs" - onClick={() => openWindow('ticket', { ticketId: mod.appealTicketId })} + onClick={() => { + // Replace # with ID- for URL compatibility (matches ticket links above) + const urlSafeTicketId = String(mod.appealTicketId).replace('#', 'ID-'); + setLocation(`/panel/tickets/${urlSafeTicketId}`); + }} > View Appeal @@ -2975,6 +2987,11 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind )} + +

{t('player.playerReplays')}

+ +
+

{t('player.previousNames')}

@@ -3007,7 +3024,7 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind statWiping: playerInfo.statWiping || false, silentPunishment: playerInfo.silentPunishment || false, kickSameIP: playerInfo.kickSameIP || false, - attachReports: playerInfo.attachReports || [], + attachReports: playerInfo.attachedReports || [], banToLink: playerInfo.banToLink || '', banLinkedAccounts: playerInfo.banLinkedAccounts || false }} @@ -3026,7 +3043,7 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind statWiping: data.statWiping || false, silentPunishment: data.silentPunishment || false, kickSameIP: data.kickSameIP || false, - attachReports: data.attachReports || [], + attachedReports: data.attachReports || [], banToLink: data.banToLink || '', banLinkedAccounts: data.banLinkedAccounts || false })); diff --git a/client/src/contexts/DashboardContext.tsx b/client/src/contexts/DashboardContext.tsx index 5ddb146..ab01600 100644 --- a/client/src/contexts/DashboardContext.tsx +++ b/client/src/contexts/DashboardContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useReducer, ReactNode, useCallback } from 'react'; +import { createContext, useContext, useReducer, useMemo, ReactNode, useCallback } from 'react'; import { WindowPosition, WindowState } from '@modl-gg/shared-web/types'; import { windowActions, initialWindowState } from '@/lib/window-manager'; @@ -55,16 +55,16 @@ export const DashboardProvider = ({ children }: { children: ReactNode }) => { dispatch({ type: 'UPDATE_SIZE', id, size }); }, []); + const contextValue = useMemo(() => ({ + windowState, + openLookupWindow, + closeLookupWindow, + updateWindowPosition, + updateWindowSize + }), [windowState, openLookupWindow, closeLookupWindow, updateWindowPosition, updateWindowSize]); + return ( - + {children} ); diff --git a/client/src/contexts/PlayerWindowContext.tsx b/client/src/contexts/PlayerWindowContext.tsx index a9669bd..f9585d5 100644 --- a/client/src/contexts/PlayerWindowContext.tsx +++ b/client/src/contexts/PlayerWindowContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from 'react'; import { createPortal } from 'react-dom'; import PlayerWindow from '@/components/windows/PlayerWindow'; @@ -29,7 +29,7 @@ const getNextWindowPosition = (existingWindows: PlayerWindowState[]): WindowPosi const baseX = 100; const baseY = 100; const offset = 50; - + const count = existingWindows.length; return { x: baseX + (count * offset), @@ -37,20 +37,38 @@ const getNextWindowPosition = (existingWindows: PlayerWindowState[]): WindowPosi }; }; +const setPlayerUrlParam = (playerId: string) => { + if (typeof window === 'undefined') return; + const url = new URL(window.location.href); + if (url.searchParams.get('player') === playerId) return; + url.searchParams.set('player', playerId); + window.history.replaceState({}, '', url.toString()); +}; + +const clearPlayerUrlParam = (playerId: string) => { + if (typeof window === 'undefined') return; + const url = new URL(window.location.href); + if (url.searchParams.get('player') !== playerId) return; + url.searchParams.delete('player'); + url.searchParams.delete('punishment'); + window.history.replaceState({}, '', url.toString()); +}; + export function PlayerWindowProvider({ children }: { children: ReactNode }) { const [windows, setWindows] = useState([]); const openPlayerWindow = useCallback((playerId: string, username?: string) => { + setPlayerUrlParam(playerId); setWindows(prevWindows => { const windowId = generateWindowId(playerId); - + const existingWindowIndex = prevWindows.findIndex(w => w.id === windowId); if (existingWindowIndex !== -1) { const existingWindow = prevWindows[existingWindowIndex]; const otherWindows = prevWindows.filter((_, index) => index !== existingWindowIndex); return [...otherWindows, { ...existingWindow, isOpen: true }]; } - + const position = getNextWindowPosition(prevWindows); const newWindow: PlayerWindowState = { id: windowId, @@ -58,31 +76,31 @@ export function PlayerWindowProvider({ children }: { children: ReactNode }) { isOpen: true, position }; - + return [...prevWindows, newWindow]; }); }, []); const closePlayerWindow = useCallback((windowId: string) => { - setWindows(prevWindows => - prevWindows.map(window => - window.id === windowId - ? { ...window, isOpen: false } - : window - ) - ); + setWindows(prevWindows => { + const closing = prevWindows.find(w => w.id === windowId); + if (closing) { + clearPlayerUrlParam(closing.playerId); + } + return prevWindows.filter(w => w.id !== windowId); + }); }, []); const focusPlayerWindow = useCallback((playerId: string, username?: string) => { openPlayerWindow(playerId, username); }, [openPlayerWindow]); - const contextValue: PlayerWindowContextType = { + const contextValue = useMemo(() => ({ windows, openPlayerWindow, closePlayerWindow, focusPlayerWindow - }; + }), [windows, openPlayerWindow, closePlayerWindow, focusPlayerWindow]); return ( diff --git a/client/src/hooks/data/appeals.ts b/client/src/hooks/data/appeals.ts new file mode 100644 index 0000000..a6b4aa9 --- /dev/null +++ b/client/src/hooks/data/appeals.ts @@ -0,0 +1,80 @@ +import { useQuery, useMutation } from '@tanstack/react-query'; +import { fromJson, type JsonValue } from '@bufbuild/protobuf'; +import { queryClient } from '../../lib/queryClient'; +import { protoFetch, protoFetchOrNull, protoSend } from '@/lib/proto-fetch'; +import { + AppealTicketsResponseSchema, + CreateAppealRequestSchema, + CreatePublicAppealResponseSchema, +} from '@modl-gg/proto/modl/v1/appeal_pb.ts'; +import type { TicketResponse } from '@modl-gg/proto/modl/v1/ticket_pb.ts'; + +// Appeals reuse the ticket TicketResponse message; its int64 date fields decode to +// bigint epoch millis, while the legacy JSON delivered them as numbers that consumers +// pass to `new Date(...)`. Convert each date back to a Number. +function mapAppealTicket(ticket: TicketResponse) { + return { + ...ticket, + _id: ticket.id, + date: Number(ticket.date), + messages: ticket.messages.map((reply) => ({ ...reply, created: Number(reply.created) })), + notes: ticket.notes.map((note) => ({ ...note, date: Number(note.date) })), + }; +} + +export function useAppeals() { + return useQuery({ + queryKey: ['/v1/panel/appeals'], + queryFn: async () => { + const response = await protoFetch(AppealTicketsResponseSchema, '/v1/panel/appeals'); + return response.tickets.map(mapAppealTicket); + } + }); +} + +export function useAppealsByPunishment(punishmentId: string) { + return useQuery({ + queryKey: ['/v1/panel/appeals/punishment', punishmentId], + queryFn: async () => { + const response = await protoFetchOrNull( + AppealTicketsResponseSchema, + `/v1/panel/appeals/punishment/${punishmentId}`, + ); + return response ? response.tickets.map(mapAppealTicket) : []; + }, + enabled: !!punishmentId + }); +} + +export function useCreateAppeal() { + return useMutation({ + // appealData keeps the legacy `any` contract so the appeals page passes its loosely + // typed payload unchanged. + mutationFn: (appealData: any) => { + // CreateAppealRequest carries google.protobuf.Value/Struct fields that create() + // cannot build from free-form JSON; fromJson coerces them natively from proto-JSON. + // fromJson rejects undefined values, so only include keys that are present. + const json: Record = { + punishmentId: appealData.punishmentId, + playerUuid: appealData.playerUuid, + email: appealData.email, + attachments: (appealData.attachments ?? []) as JsonValue, + }; + if (appealData.reason !== undefined) json.reason = appealData.reason; + if (appealData.evidence !== undefined) json.evidence = appealData.evidence; + if (appealData.additionalData !== undefined) json.additionalData = appealData.additionalData as JsonValue; + if (appealData.fieldLabels !== undefined) json.fieldLabels = appealData.fieldLabels; + + return protoSend( + 'POST', + '/v1/public/appeals', + CreateAppealRequestSchema, + fromJson(CreateAppealRequestSchema, json, { ignoreUnknownFields: true }), + CreatePublicAppealResponseSchema, + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/appeals'] }); + } + }); +} diff --git a/client/src/hooks/data/audit.ts b/client/src/hooks/data/audit.ts new file mode 100644 index 0000000..734fb56 --- /dev/null +++ b/client/src/hooks/data/audit.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { PanelLogsResponseSchema } from '@modl-gg/proto/modl/v1/admin_pb.ts'; +import { protoFetch } from '@/lib/proto-fetch'; +import { tsToDate } from '@/lib/proto-ui'; + +export interface PanelLog { + id: string; + description: string; + level: string; + source: string; + created: Date | null; +} + +export function useLogs() { + return useQuery({ + queryKey: ['/v1/panel/logs'], + queryFn: async (): Promise => { + const res = await protoFetch(PanelLogsResponseSchema, '/v1/panel/logs'); + return res.logs.map((log) => ({ + id: log.id, + description: log.description, + level: log.level, + source: log.source, + created: tsToDate(log.created), + })); + }, + }); +} diff --git a/client/src/hooks/data/billing.ts b/client/src/hooks/data/billing.ts new file mode 100644 index 0000000..38618fb --- /dev/null +++ b/client/src/hooks/data/billing.ts @@ -0,0 +1,134 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { create } from '@bufbuild/protobuf'; +import { + BillingStatusResponseSchema, + UsageResponseSchema, + CancelResponseSchema, + ResubscribeResponseSchema, + UsageBillingSettingsRequestSchema, + UsageBillingSettingsResponseSchema, + type UsageResponse_UsageMetric, +} from '@modl-gg/proto/modl/v1/billing_pb.ts'; +import { protoFetch, protoSend } from '@/lib/proto-fetch'; +import { tsToDate, tsToMillis, toNum } from '@/lib/proto-ui'; + +export interface BillingStatus { + plan: string; + subscriptionStatus: string; + currentPeriodStart: Date | null; + currentPeriodEnd: Date | null; + customDomainGrandfathered?: boolean; + maxStorageLimitBytes?: number; + maxAiOverageRequests?: number; +} + +export interface UsageMetric { + used: number; + limit: number; + overage: number; + overageRate: number; + overageCost: number; + percentage: number; +} + +export interface UsageData { + period: { start: number | null; end: number | null }; + cdn?: UsageMetric; + ai?: UsageMetric; + totalOverageCost: number; + usageBillingEnabled: boolean; +} + +const toUsageMetric = (m: UsageResponse_UsageMetric): UsageMetric => ({ + used: m.used, + limit: m.limit, + overage: m.overage, + overageRate: m.overageRate, + overageCost: m.overageCost, + percentage: m.percentage, +}); + +export function useBillingStatus() { + return useQuery({ + queryKey: ['/v1/panel/billing/status'], + queryFn: async () => { + const res = await protoFetch(BillingStatusResponseSchema, '/v1/panel/billing/status'); + return { + plan: res.plan, + subscriptionStatus: res.subscriptionStatus, + currentPeriodStart: tsToDate(res.currentPeriodStart), + currentPeriodEnd: tsToDate(res.currentPeriodEnd), + customDomainGrandfathered: res.customDomainGrandfathered, + maxStorageLimitBytes: + res.maxStorageLimitBytes !== undefined ? toNum(res.maxStorageLimitBytes) : undefined, + maxAiOverageRequests: + res.maxAiOverageRequests !== undefined ? toNum(res.maxAiOverageRequests) : undefined, + }; + }, + staleTime: 1000 * 60 * 5, + }); +} + +export function useCancelSubscription() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => + protoFetch(CancelResponseSchema, '/v1/panel/billing/cancel', { method: 'POST' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/status'] }); + }, + }); +} + +export function useUsageData() { + return useQuery({ + queryKey: ['/v1/panel/billing/usage'], + queryFn: async () => { + const res = await protoFetch(UsageResponseSchema, '/v1/panel/billing/usage'); + return { + period: { + start: tsToMillis(res.period?.start), + end: tsToMillis(res.period?.end), + }, + cdn: res.cdn ? toUsageMetric(res.cdn) : undefined, + ai: res.ai ? toUsageMetric(res.ai) : undefined, + totalOverageCost: res.totalOverageCost, + usageBillingEnabled: res.usageBillingEnabled, + }; + }, + staleTime: 1000 * 60 * 5, + }); +} + +export function useUpdateUsageBillingSettings() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ enabled }: { enabled: boolean }) => + protoSend( + 'POST', + '/v1/panel/billing/usage-settings', + UsageBillingSettingsRequestSchema, + create(UsageBillingSettingsRequestSchema, { enabled }), + UsageBillingSettingsResponseSchema, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/usage'] }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/status'] }); + }, + }); +} + +export function useResubscribe() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => + protoFetch(ResubscribeResponseSchema, '/v1/panel/billing/resubscribe', { method: 'POST' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/status'] }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/usage'] }); + }, + }); +} diff --git a/client/src/hooks/data/dashboard.ts b/client/src/hooks/data/dashboard.ts new file mode 100644 index 0000000..313f6e6 --- /dev/null +++ b/client/src/hooks/data/dashboard.ts @@ -0,0 +1,148 @@ +import { useQuery } from '@tanstack/react-query'; +import { + DashboardMetricsResponseSchema, + DashboardRecentTicketsResponseSchema, + DashboardRecentPunishmentsResponseSchema, +} from '@modl-gg/proto/modl/v1/dashboard_pb.ts'; +import { PanelSystemAlertsResponseSchema } from '@modl-gg/proto/modl/v1/alert_pb.ts'; +import { protoFetch } from '@/lib/proto-fetch'; +import { toNum } from '@/lib/proto-ui'; + +export interface DashboardMetrics { + totalTickets: number; + openTickets: number; + totalPlayers: number; + totalPunishments: number; + activePunishments: number; + totalStaff: number; + ticketsTrend: number; + playersTrend: number; +} + +export function useDashboardMetrics(period: string = '7d') { + return useQuery({ + queryKey: ['/v1/panel/dashboard/metrics', period], + queryFn: async (): Promise => { + const res = await protoFetch( + DashboardMetricsResponseSchema, + `/v1/panel/dashboard/metrics?period=${period}`, + ); + return { + totalTickets: toNum(res.totalTickets), + openTickets: toNum(res.openTickets), + totalPlayers: toNum(res.totalPlayers), + totalPunishments: toNum(res.totalPunishments), + activePunishments: toNum(res.activePunishments), + totalStaff: toNum(res.totalStaff), + ticketsTrend: res.ticketsTrend, + playersTrend: res.playersTrend, + }; + }, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: true, + }); +} + +export interface RecentTicket { + id: string; + title: string; + initialMessage: string; + status: string; + priority: string; + createdAt: Date; + playerName: string; + type: string; +} + +export function useRecentTickets(limit: number = 5) { + return useQuery({ + queryKey: ['/v1/panel/dashboard/recent-tickets', limit], + queryFn: async (): Promise => { + const res = await protoFetch( + DashboardRecentTicketsResponseSchema, + `/v1/panel/dashboard/recent-tickets?limit=${limit}`, + ); + return res.tickets.map((ticket) => ({ + id: ticket.id, + title: ticket.title, + initialMessage: ticket.initialMessage, + status: ticket.status, + priority: ticket.priority, + createdAt: new Date(toNum(ticket.createdAt)), + playerName: ticket.playerName, + type: ticket.type, + })); + }, + staleTime: 2 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} + +export interface RecentPunishment { + id: string; + playerName: string; + playerUuid: string; + type: string; + reason: string; + issuerName: string; + issued: Date; + active: boolean; +} + +export function useRecentPunishments(limit: number = 10) { + return useQuery({ + queryKey: ['/v1/panel/dashboard/recent-punishments', limit], + queryFn: async (): Promise => { + const res = await protoFetch( + DashboardRecentPunishmentsResponseSchema, + `/v1/panel/dashboard/recent-punishments?limit=${limit}`, + ); + return res.punishments.map((punishment) => ({ + id: punishment.id, + playerName: punishment.playerName, + playerUuid: punishment.playerUuid, + type: punishment.type, + reason: punishment.reason, + issuerName: punishment.issuerName, + issued: new Date(toNum(punishment.issued)), + active: punishment.active, + })); + }, + staleTime: 2 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} + +export type SystemAlertSeverity = 'BASIC' | 'WARNING' | 'CRITICAL'; +export type SystemAlertAudience = 'ALL_PANEL_USERS' | 'SUPER_ADMINS_ONLY'; + +export interface SystemAlert { + id: string; + message: string; + severity: SystemAlertSeverity; + audience: SystemAlertAudience; + expiresAt?: string; + createdAt?: string; + updatedAt?: string; +} + +export function useDashboardAlerts() { + return useQuery({ + queryKey: ['/v1/panel/dashboard/alerts'], + queryFn: async (): Promise => { + const res = await protoFetch( + PanelSystemAlertsResponseSchema, + '/v1/panel/dashboard/alerts', + ); + return res.items.map((alert) => ({ + id: alert.id, + message: alert.message, + severity: alert.severity as SystemAlertSeverity, + audience: alert.audience as SystemAlertAudience, + expiresAt: alert.expiresAt ? new Date(toNum(alert.expiresAt)).toISOString() : undefined, + })); + }, + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/client/src/hooks/data/migration.ts b/client/src/hooks/data/migration.ts new file mode 100644 index 0000000..c78b7f1 --- /dev/null +++ b/client/src/hooks/data/migration.ts @@ -0,0 +1,129 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { create } from '@bufbuild/protobuf'; +import { + MigrationStatusResponseSchema, + StartMigrationRequestSchema, + MigrationOperationResponseSchema, + type MigrationStatusResponse, +} from '@modl-gg/proto/modl/v1/migration_pb.ts'; +import { protoFetch, protoSend, ProtoHttpError } from '@/lib/proto-fetch'; +import { tsToMillis, toNum } from '@/lib/proto-ui'; + +// The migration status endpoint historically returned plain JSON consumed loosely by +// MigrationTool.tsx (epoch-millis timestamps, numeric cooldown). Decode the proto message +// for wire correctness, then remap back to that legacy shape so the component needs no edits: +// int64 cooldown.remainingTime -> number, Timestamp startedAt/completedAt -> epoch millis. +// Return type stays `any` to match the previous res.json() contract MigrationTool.tsx relies on +// (it reads loose/optional fields the backend has never sent, e.g. history/lastMigrationTimestamp). +function remapMigrationStatus(res: MigrationStatusResponse): any { + const current = res.currentMigration; + const cooldown = res.cooldown; + return { + currentMigration: current + ? { + taskId: current.taskId, + type: current.type, + status: current.status, + progress: current.progress + ? { + message: current.progress.message, + recordsProcessed: current.progress.recordsProcessed, + recordsSkipped: current.progress.recordsSkipped, + totalRecords: current.progress.totalRecords, + } + : undefined, + startedAt: tsToMillis(current.startedAt), + completedAt: tsToMillis(current.completedAt), + error: current.error, + } + : undefined, + cooldown: cooldown + ? { + onCooldown: cooldown.onCooldown, + remainingTime: cooldown.remainingTime !== undefined ? toNum(cooldown.remainingTime) : undefined, + } + : undefined, + }; +} + +export function useMigrationStatus() { + return useQuery({ + queryKey: ['/v1/panel/migration/status'], + queryFn: async () => { + const res = await protoFetch(MigrationStatusResponseSchema, '/v1/panel/migration/status'); + return remapMigrationStatus(res); + }, + // Always keep a polling safety-net: realtime invalidation (migrationStatusChanged) refreshes + // promptly when the socket is healthy, but the WS can be permanently stopped for an auth + // session (close 1008/1013 -> stopForAuthSession) while the env flag still reads "enabled". + // Gating polling purely on that static flag would freeze an in-progress migration's progress. + // Poll fast (5s) while a migration is active, slow (30s) otherwise as a background fallback. + refetchInterval: (query) => { + const data = query.state.data; + const currentMigration = data?.currentMigration; + const isActive = currentMigration && + currentMigration.status !== 'completed' && + currentMigration.status !== 'failed'; + + return isActive ? 5000 : 30000; + } + }); +} + +export function useStartMigration() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ migrationType }: { migrationType: string }) => { + try { + return await protoSend( + 'POST', + '/v1/panel/migration/start', + StartMigrationRequestSchema, + create(StartMigrationRequestSchema, { migrationType }), + MigrationOperationResponseSchema, + ); + } catch (error) { + throw migrationOperationError(error, 'Failed to start migration'); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/migration/status'] }); + } + }); +} + +export function useCancelMigration() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + try { + return await protoFetch( + MigrationOperationResponseSchema, + '/v1/panel/migration/cancel', + { method: 'POST' }, + ); + } catch (error) { + throw migrationOperationError(error, 'Failed to cancel migration'); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/migration/status'] }); + } + }); +} + +// /v1 error bodies stay the legacy {status,error} JSON envelope, so parse the raw body text +// to preserve the original `errorData.error || fallback` message surfaced to the user. +function migrationOperationError(error: unknown, fallback: string): Error { + if (error instanceof ProtoHttpError) { + try { + const body = JSON.parse(error.bodyText); + return new Error(body?.error || fallback); + } catch { + return new Error(fallback); + } + } + return error instanceof Error ? error : new Error(fallback); +} diff --git a/client/src/hooks/data/players.ts b/client/src/hooks/data/players.ts new file mode 100644 index 0000000..4983429 --- /dev/null +++ b/client/src/hooks/data/players.ts @@ -0,0 +1,139 @@ +import React from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { queryClient } from '../../lib/queryClient'; +import { protoFetch, protoFetchOrNull } from '@/lib/proto-fetch'; +import { toNum } from '@/lib/proto-ui'; +import { mapPunishment } from '@/lib/punishment-mapping'; +import { + PlayerDetailResponseSchema, + PanelLinkedAccountsResponseSchema, + PanelFindAndLinkAccountsResponseSchema, + PlayerReplaysResponseSchema, + PlayerSearchResultsResponseSchema, + type PlayerDetailResponse, + type PlayerReplayResponse, +} from '@modl-gg/proto/modl/v1/player_pb.ts'; + +export interface PlayerReplaySummary { + replayId: string; + targetUuid: string; + targetName: string; + mcVersion: string; + fileSize: number; + createdAt: string; + status: string; + replayUrl: string; + matchSource: string; +} + +// Remap PlayerDetailResponse to the legacy player object the player pages consume. `latestIpData` +// is renamed to `latestIPData` (the casing every consumer reads), and `data` stays a plain object +// so the existing `player.data[key]` lookups keep working. The return is intentionally `any`: the +// pages read this object loosely (the legacy `res.json()` contract), so a precise type would only +// surface pre-existing loose-access patterns as new errors. +const mapPlayerDetail = (player: PlayerDetailResponse): any => ({ + ...player, + latestIPData: player.latestIpData, + punishments: player.punishments.map(mapPunishment), +}); + +const mapReplay = (replay: PlayerReplayResponse): PlayerReplaySummary => ({ + replayId: replay.replayId, + targetUuid: replay.targetUuid, + targetName: replay.targetName, + mcVersion: replay.mcVersion, + fileSize: toNum(replay.fileSize), + createdAt: new Date(toNum(replay.createdAt)).toISOString(), + status: replay.status, + replayUrl: replay.replayUrl, + matchSource: replay.matchSource, +}); + +export function usePlayer(uuid: string) { + return useQuery({ + queryKey: ['/v1/panel/players', uuid], + queryFn: async () => { + const response = await protoFetchOrNull(PlayerDetailResponseSchema, `/v1/panel/players/${uuid}`); + return response ? mapPlayerDetail(response) : null; + }, + enabled: !!uuid, + staleTime: 30_000, + refetchOnWindowFocus: true + }); +} + +export function useLinkedAccounts(uuid: string) { + return useQuery({ + queryKey: ['/v1/panel/players/linked', uuid], + queryFn: async () => { + const response = await protoFetchOrNull( + PanelLinkedAccountsResponseSchema, + `/v1/panel/players/${uuid}/linked`, + ); + // Loose return: the page also reads legacy bookkeeping fields (searchStatus, etc.) that the + // proto response no longer carries, so a precise type would only add new errors. + return { linkedAccounts: response?.linkedAccounts ?? [] } as any; + }, + enabled: !!uuid, + staleTime: 30_000, + refetchOnWindowFocus: true + }); +} + +export function useFindLinkedAccounts() { + return useMutation({ + mutationFn: (minecraftUuid: string) => + protoFetch( + PanelFindAndLinkAccountsResponseSchema, + `/v1/panel/players/${minecraftUuid}/find-linked`, + { method: 'POST' }, + ), + onSuccess: (_data, minecraftUuid) => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/players/linked', minecraftUuid] }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/players', minecraftUuid] }); + } + }); +} + +export function usePlayerReplays(uuid: string) { + return useQuery({ + queryKey: ['/v1/panel/players', uuid, 'replays'], + queryFn: async () => { + const response = await protoFetchOrNull(PlayerReplaysResponseSchema, `/v1/panel/players/${uuid}/replays`); + return response ? response.items.map(mapReplay) : []; + }, + enabled: !!uuid, + staleTime: 30000, + refetchOnWindowFocus: true + }); +} + +export function usePlayerSearch(searchQuery: string, debounceMs: number = 300) { + const [debouncedQuery, setDebouncedQuery] = React.useState(searchQuery); + + React.useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchQuery); + }, debounceMs); + + return () => clearTimeout(timer); + }, [searchQuery, debounceMs]); + + return useQuery({ + queryKey: ['player-search', debouncedQuery], + queryFn: async () => { + if (!debouncedQuery || debouncedQuery.trim().length < 2) { + return []; + } + const response = await protoFetch( + PlayerSearchResultsResponseSchema, + `/v1/panel/players?search=${encodeURIComponent(debouncedQuery.trim())}`, + ); + // Loose array: some consumers read `player.minecraftUuid`/`player.data`, which the search + // result message does not expose, so keep the legacy untyped contract. + return response.items as any[]; + }, + enabled: debouncedQuery.trim().length >= 2, + staleTime: 1000 * 60, + }); +} diff --git a/client/src/hooks/data/punishments.ts b/client/src/hooks/data/punishments.ts new file mode 100644 index 0000000..8e82af1 --- /dev/null +++ b/client/src/hooks/data/punishments.ts @@ -0,0 +1,280 @@ +import { useQuery, useMutation } from '@tanstack/react-query'; +import { create, fromJson, type JsonValue } from '@bufbuild/protobuf'; +import { queryClient } from '../../lib/queryClient'; +import { useAuth } from '../use-auth'; +import { protoFetch, protoSend, protoFetchOrNull, ProtoHttpError } from '@/lib/proto-fetch'; +import { SimpleResponseSchema } from '@modl-gg/proto/modl/v1/common_pb.ts'; +import { + PanelCreatePunishmentRequestSchema, + PanelAddModificationRequestSchema, + PanelAddPunishmentNoteRequestSchema, + ModifyPunishmentTicketsRequestSchema, + PunishmentResponseSchema, +} from '@modl-gg/proto/modl/v1/punishment_pb.ts'; +import { mapPunishment } from '@/lib/punishment-mapping'; +import { + PanelLinkedBansResponseSchema, +} from '@modl-gg/proto/modl/v1/player_pb.ts'; +import { + PanelPunishmentTypesResponseSchema, +} from '@modl-gg/proto/modl/v1/settings_pb.ts'; + +// The /v1 error envelope stays the legacy {status,error,...} JSON, so the rich permission-denied +// message the punishment UI relies on must be reconstructed from the raw body text. +function applyPunishmentError(error: unknown): Error { + if (error instanceof ProtoHttpError) { + let parsed: { error?: string; message?: string; punishmentType?: string } | null = null; + try { + parsed = JSON.parse(error.bodyText); + } catch { + parsed = null; + } + + if (error.status === 403) { + let message = `Permission denied: ${parsed?.error || 'You do not have permission to apply this punishment type'}`; + if (parsed?.punishmentType) { + message += ` (${parsed.punishmentType})`; + } + return new Error(message); + } + + const message = parsed?.error || parsed?.message + || error.bodyText + || `Failed to apply punishment: ${error.status} ${error.statusText}`; + return new Error(message); + } + + return error instanceof Error ? error : new Error('Failed to apply punishment'); +} + +export function useApplyPunishment() { + return useMutation({ + mutationFn: async ({ uuid, punishmentData }: { uuid: string, punishmentData: any }) => { + // punishmentData keeps the legacy `any` contract (the player/ticket pages build it in + // proto-JSON shape with a free-form `data` Struct and number-typed int64 fields). fromJson + // coerces those natively; create() cannot. Strip undefined keys, which fromJson rejects. + const json: Record = {}; + for (const [key, value] of Object.entries(punishmentData)) { + if (value !== undefined && value !== null) { + json[key] = value as JsonValue; + } + } + + try { + return await protoSend( + 'POST', + `/v1/panel/players/${uuid}/punishments`, + PanelCreatePunishmentRequestSchema, + fromJson(PanelCreatePunishmentRequestSchema, json, { ignoreUnknownFields: true }), + SimpleResponseSchema, + ); + } catch (error) { + throw applyPunishmentError(error); + } + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/players', variables.uuid] }); + }, + onError: (error) => { + console.error('Error applying punishment:', error); + } + }); +} + +export function useModifyPunishment() { + const { user } = useAuth(); + + return useMutation({ + mutationFn: async ({ + uuid, + punishmentId, + modificationType, + reason, + newDuration, + appealTicketId + }: { + uuid: string, + punishmentId: string, + modificationType: string, + reason: string, + newDuration?: { value: number; unit: string }, + appealTicketId?: string + }) => { + const request = create(PanelAddModificationRequestSchema, { + type: modificationType, + issuerName: user?.username || 'Unknown User', + issuerId: user?.id, + reason + }); + + if (appealTicketId) { + request.appealTicketId = appealTicketId; + } + + if ((modificationType === 'MANUAL_DURATION_CHANGE' || modificationType === 'APPEAL_DURATION_CHANGE') && newDuration) { + const multipliers = { + 'seconds': 1000, + 'minutes': 60 * 1000, + 'hours': 60 * 60 * 1000, + 'days': 24 * 60 * 60 * 1000, + 'weeks': 7 * 24 * 60 * 60 * 1000, + 'months': 30 * 24 * 60 * 60 * 1000 + }; + + // An unrecognized unit must NOT silently fall back to 0ms: the backend treats + // effectiveDuration <= 0 as PERMANENT, so a typo/casing drift would convert a finite + // duration change into a permanent punishment with no feedback. Validate and throw instead. + const multiplier = multipliers[newDuration.unit as keyof typeof multipliers]; + if (multiplier === undefined) { + throw new Error(`Invalid duration unit: "${newDuration.unit}". Expected one of ${Object.keys(multipliers).join(', ')}.`); + } + + const durationMs = newDuration.value * multiplier; + request.effectiveDuration = BigInt(Math.trunc(durationMs)); + } + + return protoSend( + 'POST', + `/v1/panel/players/${uuid}/punishments/${punishmentId}/modifications`, + PanelAddModificationRequestSchema, + request, + SimpleResponseSchema, + ); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/players', variables.uuid] }); + }, + onError: (error) => { + console.error('Error modifying punishment:', error); + } + }); +} + +export function useAddPunishmentNote() { + const { user } = useAuth(); + + return useMutation({ + mutationFn: async ({ + uuid, + punishmentId, + noteText + }: { + uuid: string, + punishmentId: string, + noteText: string + }) => { + const request = create(PanelAddPunishmentNoteRequestSchema, { + text: noteText, + issuerName: user?.username || 'Unknown User', + issuerId: user?.id + }); + + return protoSend( + 'POST', + `/v1/panel/players/${uuid}/punishments/${punishmentId}/notes`, + PanelAddPunishmentNoteRequestSchema, + request, + SimpleResponseSchema, + ); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/players', variables.uuid] }); + }, + onError: (error) => { + console.error('Error adding punishment note:', error); + } + }); +} + +export function useModifyPunishmentTickets() { + const { user } = useAuth(); + + return useMutation({ + mutationFn: async ({ + uuid, + punishmentId, + addTicketIds, + removeTicketIds, + modifyAssociatedTickets + }: { + uuid: string, + punishmentId: string, + addTicketIds?: string[], + removeTicketIds?: string[], + modifyAssociatedTickets: boolean + }) => { + const request = create(ModifyPunishmentTicketsRequestSchema, { + punishmentId, + issuerName: user?.username || 'Unknown User', + issuerId: user?.id, + addTicketIds: addTicketIds ?? [], + removeTicketIds: removeTicketIds ?? [], + modifyAssociatedTickets + }); + + return protoSend( + 'POST', + `/v1/panel/players/${uuid}/punishments/${punishmentId}/tickets`, + ModifyPunishmentTicketsRequestSchema, + request, + SimpleResponseSchema, + ); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/players', variables.uuid] }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets'] }); + }, + onError: (error) => { + console.error('Error modifying punishment tickets:', error); + } + }); +} + +export function usePunishmentById(punishmentId: string | null) { + return useQuery({ + queryKey: ['/v1/panel/players/punishments', punishmentId], + queryFn: async (): Promise => { + if (!punishmentId) return null; + const response = await protoFetchOrNull( + PunishmentResponseSchema, + `/v1/panel/players/punishments/${punishmentId}`, + ); + return response ? mapPunishment(response) : null; + }, + enabled: !!punishmentId, + staleTime: 30000, + }); +} + +export function useLinkedBansForPunishment(punishmentId: string | null) { + return useQuery({ + queryKey: ['/v1/panel/players/punishments', punishmentId, 'linked-bans'], + queryFn: async () => { + if (!punishmentId) return []; + const response = await protoFetchOrNull( + PanelLinkedBansResponseSchema, + `/v1/panel/players/punishments/${punishmentId}/linked-bans`, + ); + return response?.linkedBans ?? []; + }, + enabled: !!punishmentId, + staleTime: 30000, + }); +} + +export function usePunishmentTypes() { + return useQuery({ + queryKey: ['/v1/panel/settings/punishment-types'], + queryFn: async () => { + const response = await protoFetch( + PanelPunishmentTypesResponseSchema, + '/v1/panel/settings/punishment-types', + ); + // Loose array: consumers bucket these into their own local PunishmentType shape (a different + // type than the proto message), so keep the legacy untyped contract to avoid new errors. + return response.punishmentTypes as any[]; + }, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); +} diff --git a/client/src/hooks/data/settings.ts b/client/src/hooks/data/settings.ts new file mode 100644 index 0000000..cc8db1e --- /dev/null +++ b/client/src/hooks/data/settings.ts @@ -0,0 +1,239 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { fromJson, type DescMessage, type MessageShape } from '@bufbuild/protobuf'; +import { + GeneralSettingsEnvelopeSchema, + TicketFormSettingsEnvelopeSchema, + QuickResponseSettingsEnvelopeSchema, + OffenderThresholdSettingsEnvelopeSchema, + TicketLabelSettingsEnvelopeSchema, + ReplayRetentionSettingsEnvelopeSchema, + WebhookSettingsSchema, + PublicSettingsResponseSchema, + type SettingsMeta, +} from '@modl-gg/proto/modl/v1/settings_pb.ts'; +import { apiFetch } from '@/lib/api'; +import { protoFetch, ProtoHttpError } from '@/lib/proto-fetch'; +import { tsToDate } from '@/lib/proto-ui'; +import { isPublicPage } from '@/utils/routes'; + +type SettingsEnvelope = { + data: T; + _meta?: { + version: number; + updatedAt?: string | null; + } | null; +}; + +function metaToShape(meta?: SettingsMeta): SettingsEnvelope['_meta'] { + if (!meta) { + return { version: 0, updatedAt: null }; + } + const updatedAt = tsToDate(meta.updatedAt); + return { + version: Number(meta.version), + updatedAt: updatedAt ? updatedAt.toISOString() : null, + }; +} + +async function fetchSettingsEnvelope( + schema: Desc, + path: string, +): Promise | null> { + let decoded: MessageShape; + try { + decoded = await protoFetch(schema, path); + } catch (error) { + if (error instanceof ProtoHttpError && (error.status === 401 || error.status === 403)) { + return null; + } + throw error; + } + const message = decoded as unknown as { data?: unknown; meta?: SettingsMeta }; + return { + data: message.data ?? null, + _meta: metaToShape(message.meta), + }; +} + +export interface ReplayRetentionSettings { + enabled: boolean; + days: number; +} + +export function useSettings() { + return useQuery({ + queryKey: ['/v1/settings'], + queryFn: async () => { + const isPublic = isPublicPage(); + + const fetchPublic = async () => { + const publicData = await protoFetch(PublicSettingsResponseSchema, '/v1/public/settings'); + return { + settings: { + general: { + serverDisplayName: publicData.serverDisplayName, + panelIconUrl: publicData.panelIconUrl, + homepageIconUrl: publicData.homepageIconUrl + }, + ticketForms: publicData.ticketForms || {} + } + }; + }; + + try { + if (isPublic) { + return await fetchPublic(); + } + + let generalEnvelope: SettingsEnvelope | null = null; + try { + const decoded = await protoFetch(GeneralSettingsEnvelopeSchema, '/v1/panel/settings/general'); + generalEnvelope = { + data: decoded.data ?? {}, + _meta: metaToShape(decoded.meta), + }; + } catch (error) { + if (error instanceof ProtoHttpError && error.status === 401) { + return await fetchPublic(); + } + throw error; + } + + let webhookSettings: any = null; + try { + webhookSettings = await protoFetch(WebhookSettingsSchema, '/v1/panel/settings/webhooks'); + } catch { + webhookSettings = null; + } + + const general = generalEnvelope.data || {}; + return { + settings: { + ...general, + general, + generalMeta: generalEnvelope._meta || null, + webhookSettings + } + }; + } catch (error) { + try { + if (isPublic) { + const decoded = await protoFetch(GeneralSettingsEnvelopeSchema, '/v1/panel/settings/general'); + const general = decoded.data || {}; + return { + settings: { + ...general, + general, + generalMeta: metaToShape(decoded.meta), + } + }; + } + return await fetchPublic(); + } catch { + return { + settings: { + general: { + serverDisplayName: 'modl', + panelIconUrl: null, + homepageIconUrl: null + }, + ticketForms: {} + } + }; + } + } + }, + staleTime: 300000, + refetchOnWindowFocus: false, + gcTime: 1000 * 60 * 5, + refetchInterval: false, + refetchOnReconnect: false + }); +} + +export function useTicketFormSettings() { + return useQuery | null>({ + queryKey: ['/v1/panel/settings/ticket-forms'], + queryFn: () => fetchSettingsEnvelope(TicketFormSettingsEnvelopeSchema, '/v1/panel/settings/ticket-forms'), + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); +} + +export function useQuickResponses() { + return useQuery | null>({ + queryKey: ['/v1/panel/settings/quick-responses'], + queryFn: () => fetchSettingsEnvelope(QuickResponseSettingsEnvelopeSchema, '/v1/panel/settings/quick-responses'), + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); +} + +export function useStatusThresholds() { + return useQuery | null>({ + queryKey: ['/v1/panel/settings/status-thresholds'], + queryFn: () => fetchSettingsEnvelope(OffenderThresholdSettingsEnvelopeSchema, '/v1/panel/settings/status-thresholds'), + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); +} + +export function useTicketLabelSettings() { + return useQuery | null>({ + queryKey: ['/v1/panel/settings/ticket-labels'], + queryFn: () => fetchSettingsEnvelope(TicketLabelSettingsEnvelopeSchema, '/v1/panel/settings/ticket-labels'), + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); +} + +export function useReplayRetentionSettings() { + return useQuery | null>({ + queryKey: ['/v1/panel/settings/replay-retention'], + queryFn: () => fetchSettingsEnvelope(ReplayRetentionSettingsEnvelopeSchema, '/v1/panel/settings/replay-retention') as Promise | null>, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); +} + +export function useUpdateReplayRetentionSettings() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (settings: ReplayRetentionSettings & { expectedVersion: number }): Promise> => { + const res = await apiFetch('/v1/panel/settings/replay-retention', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(settings) + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(errorText || 'Failed to update replay retention settings'); + } + + const decoded = fromJson(ReplayRetentionSettingsEnvelopeSchema, await res.json(), { ignoreUnknownFields: true }); + return { + data: (decoded.data ?? { enabled: false, days: 0 }) as ReplayRetentionSettings, + _meta: metaToShape(decoded.meta), + }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/settings/replay-retention'] }); + }, + }); +} + +export function useLabels() { + return useQuery({ + queryKey: ['/v1/panel/settings/ticket-labels', 'labels'], + queryFn: async () => { + const envelope = await fetchSettingsEnvelope(TicketLabelSettingsEnvelopeSchema, '/v1/panel/settings/ticket-labels'); + return (envelope?.data as any)?.labels || []; + }, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); +} diff --git a/client/src/hooks/data/staff.ts b/client/src/hooks/data/staff.ts new file mode 100644 index 0000000..957a977 --- /dev/null +++ b/client/src/hooks/data/staff.ts @@ -0,0 +1,186 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { create } from '@bufbuild/protobuf'; +import { + PanelStaffListResponseSchema, + PanelRoleListResponseSchema, + PermissionsResponseSchema, + RoleRequestSchema, + RoleMutationResponseSchema, + AssignMinecraftPlayerRequestSchema, + StaffMutationResponseSchema, +} from '@modl-gg/proto/modl/v1/staff_pb.ts'; +import { protoFetch, protoSend } from '@/lib/proto-fetch'; +import { toNum } from '@/lib/proto-ui'; + +export interface StaffMember { + id: string; + email: string; + username: string; + role: string; + status: string; + createdAt: string; + assignedMinecraftUuid?: string; + assignedMinecraftUsername?: string; +} + +export interface StaffRole { + id: string; + name: string; + description: string; + permissions: string[]; + isDefault: boolean; + order: number; + userCount: number; +} + +export interface RolesResult { + roles: StaffRole[]; +} + +export type StaffPermissionCategory = 'punishment' | 'ticket' | 'admin' | 'staff'; + +export interface StaffPermission { + id: string; + name: string; + description: string; + category: StaffPermissionCategory; + parentId: string | null; +} + +export interface PermissionsResult { + permissions: StaffPermission[]; + categories: { [key: string]: string }; +} + +export function useStaff() { + return useQuery({ + queryKey: ['/v1/panel/staff'], + queryFn: async () => { + const res = await protoFetch(PanelStaffListResponseSchema, '/v1/panel/staff'); + return res.staff.map((member) => ({ + id: member.id, + email: member.email, + username: member.username, + role: member.role, + status: member.status, + createdAt: new Date(toNum(member.createdAt)).toISOString(), + assignedMinecraftUuid: member.assignedMinecraftUuid || undefined, + assignedMinecraftUsername: member.assignedMinecraftUsername || undefined, + })); + }, + staleTime: 1000 * 60 * 5, + }); +} + +export function useRoles() { + return useQuery({ + queryKey: ['/v1/panel/roles'], + queryFn: async () => { + const res = await protoFetch(PanelRoleListResponseSchema, '/v1/panel/roles'); + return { + roles: res.roles.map((role) => ({ + id: role.id, + name: role.name, + description: role.description, + permissions: role.permissions, + isDefault: role.isDefault, + order: role.order, + userCount: role.userCount, + })), + }; + }, + }); +} + +export function usePermissions() { + return useQuery({ + queryKey: ['/v1/panel/roles/permissions'], + queryFn: async () => { + const res = await protoFetch(PermissionsResponseSchema, '/v1/panel/roles/permissions'); + return { + permissions: res.permissions.map((permission) => ({ + id: permission.id, + name: permission.name, + description: permission.description, + category: permission.category as StaffPermissionCategory, + parentId: permission.parentId ?? null, + })), + categories: res.categories, + }; + }, + }); +} + +export function useCreateRole() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (roleData: { name: string; description: string; permissions: string[] }) => + protoSend( + 'POST', + '/v1/panel/roles', + RoleRequestSchema, + create(RoleRequestSchema, roleData), + RoleMutationResponseSchema, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/roles'] }); + }, + }); +} + +export function useUpdateRole() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, ...roleData }: { id: string; name: string; description: string; permissions: string[] }) => + protoSend( + 'PUT', + `/v1/panel/roles/${id}`, + RoleRequestSchema, + create(RoleRequestSchema, roleData), + RoleMutationResponseSchema, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/roles'] }); + }, + }); +} + +export function useDeleteRole() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (roleId: string) => + protoFetch(RoleMutationResponseSchema, `/v1/panel/roles/${roleId}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/roles'] }); + }, + }); +} + +export function useAssignMinecraftPlayer() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + username, + minecraftUuid, + minecraftUsername + }: { + username: string; + minecraftUuid?: string; + minecraftUsername?: string; + }) => + protoSend( + 'PATCH', + `/v1/panel/staff/${username}/minecraft-player`, + AssignMinecraftPlayerRequestSchema, + create(AssignMinecraftPlayerRequestSchema, { minecraftUuid, minecraftUsername }), + StaffMutationResponseSchema, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/staff'] }); + }, + }); +} diff --git a/client/src/hooks/data/ticket-subscriptions.ts b/client/src/hooks/data/ticket-subscriptions.ts new file mode 100644 index 0000000..e767234 --- /dev/null +++ b/client/src/hooks/data/ticket-subscriptions.ts @@ -0,0 +1,97 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { protoFetch } from '@/lib/proto-fetch'; +import { + SubscriptionUpdatesResponseSchema, + type SubscriptionUpdateResponse, + DeleteTicketSubscriptionResponseSchema, + MarkSubscriptionUpdateReadResponseSchema, + MarkTicketSubscriptionReadResponseSchema, +} from '@modl-gg/proto/modl/v1/ticket_pb.ts'; + +// Consumers render the bare updates array and feed replyAt through formatTimeAgo +// (which calls `new Date(...)`). The legacy JSON delivered replyAt as a Date string, +// while proto decodes int64 epoch millis to bigint — convert it back to an ISO string. +function mapUpdate(update: SubscriptionUpdateResponse) { + return { ...update, replyAt: new Date(Number(update.replyAt)).toISOString() }; +} + +export function useTicketSubscriptionUpdates(limit: number = 10) { + return useQuery({ + queryKey: ['/v1/panel/ticket-subscriptions/updates', limit], + queryFn: async () => { + const response = await protoFetch( + SubscriptionUpdatesResponseSchema, + `/v1/panel/ticket-subscriptions/updates?limit=${limit}`, + ); + return response.updates.map(mapUpdate); + }, + staleTime: 30 * 1000, + refetchOnWindowFocus: true, + }); +} + +export function useUnsubscribeFromTicket() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (ticketId: string): Promise => + protoFetch( + DeleteTicketSubscriptionResponseSchema, + `/v1/panel/ticket-subscriptions/${ticketId}`, + { method: 'DELETE' }, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions'] }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions/updates'] }); + }, + }); +} + +export function useMarkSubscriptionUpdateAsRead() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (updateId: string): Promise => + protoFetch( + MarkSubscriptionUpdateReadResponseSchema, + `/v1/panel/ticket-subscriptions/updates/${encodeURIComponent(updateId)}/read`, + { method: 'POST' }, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions/updates'] }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions/assigned-updates'] }); + }, + }); +} + +export function useMarkTicketAsRead() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (ticketId: string): Promise => + protoFetch( + MarkTicketSubscriptionReadResponseSchema, + `/v1/panel/ticket-subscriptions/tickets/${encodeURIComponent(ticketId)}/read`, + { method: 'POST' }, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions/updates'] }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions/assigned-updates'] }); + }, + }); +} + +export function useAssignedTicketUpdates(limit: number = 10) { + return useQuery({ + queryKey: ['/v1/panel/ticket-subscriptions/assigned-updates', limit], + queryFn: async () => { + const response = await protoFetch( + SubscriptionUpdatesResponseSchema, + `/v1/panel/ticket-subscriptions/assigned-updates?limit=${limit}`, + ); + return response.updates.map(mapUpdate); + }, + staleTime: 30 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/client/src/hooks/data/tickets.ts b/client/src/hooks/data/tickets.ts new file mode 100644 index 0000000..738e200 --- /dev/null +++ b/client/src/hooks/data/tickets.ts @@ -0,0 +1,471 @@ +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; +import { create, fromJson, toJson, type JsonObject, type MessageInitShape } from '@bufbuild/protobuf'; +import type { Timestamp } from '@bufbuild/protobuf/wkt'; +import { queryClient } from '../../lib/queryClient'; +import { apiFetch } from '@/lib/api'; +import { protoFetch, protoFetchOrNull, protoSend } from '@/lib/proto-fetch'; +import { tsToDate } from '@/lib/proto-ui'; +import { getApiErrorMessage } from '@/utils/email-validation'; +import { + PaginatedTicketsResponseSchema, + type PaginatedTicketsResponse, + type TicketListItemResponse, + TicketResponseSchema, + type TicketResponse, + type TicketReply, + type TicketNote, + type TicketChatMessage, + TicketCountsResponseSchema, + CreateTicketRequestSchema, + CreateTicketResponseSchema, + UpdateTicketRequestSchema, + AddReplyRequestSchema, + AddNoteRequestSchema, + AddTicketReplyResponseSchema, + SubmitTicketFormRequestSchema, + SubmitPublicTicketResponseSchema, + PublicTicketVerificationRequestResponseSchema, + VerifyTicketCodeRequestSchema, + PublicTicketVerificationResponseSchema, + BulkTicketUpdateRequestSchema, + BulkTicketUpdateResponseSchema, + PublicTicketResponseSchema, + type PublicTicketResponse, + type PublicTicketReply, + type PublicTicketNote, +} from '@modl-gg/proto/modl/v1/ticket_pb.ts'; + +const READ_OPTS = { ignoreUnknownFields: true } as const; + +export function setCookie(name: string, value: string, days: number) { + const expires = new Date(Date.now() + days * 864e5).toUTCString(); + document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; +} + +function getCookie(name: string): string | null { + const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? decodeURIComponent(match[2]) : null; +} + +// The legacy panel JSON serialized java.util.Date as epoch millis; consumers feed those +// values straight into `new Date(...)`. Proto int64 date fields decode to bigint millis, +// so convert them back to a Number that `new Date()` accepts. +const millisToNumber = (v: bigint): number => Number(v); + +// PublicTicket* messages carry google.protobuf.Timestamp; the public ticket page feeds +// these dates through `new Date(...)`, so emit ISO strings (the prior wire shape). +const timestampToIso = (t?: Timestamp): string | undefined => { + const date = tsToDate(t); + return date ? date.toISOString() : undefined; +}; + +function mapReply(reply: TicketReply) { + return { ...reply, created: millisToNumber(reply.created) }; +} + +function mapNote(note: TicketNote) { + return { ...note, date: millisToNumber(note.date) }; +} + +function mapChatMessage(message: TicketChatMessage) { + return { content: message.content, sender: message.sender, timestamp: millisToNumber(message.timestamp) }; +} + +// Returns `any` on purpose: the legacy hook returned res.json() (untyped), and consumers +// (including the frozen ticket-detail.tsx) read fields the proto message does not model. +// Keeping the loose contract leaves those consumers untouched. +function mapTicketResponse(ticket: TicketResponse): any { + return { + ...ticket, + _id: ticket.id, + date: millisToNumber(ticket.date), + messages: ticket.messages.map(mapReply), + notes: ticket.notes.map(mapNote), + chatMessages: ticket.chatMessages.map(mapChatMessage), + }; +} + +function mapListItem(item: TicketListItemResponse) { + return { + ...item, + date: millisToNumber(item.date), + lastReply: item.lastReply ? mapReply(item.lastReply) : undefined, + }; +} + +// Returns `any` to preserve the legacy untyped contract; the tickets page maps the items +// onto its own local Ticket interface (string date fields) without further hook edits. +function mapPaginatedTickets(response: PaginatedTicketsResponse): any { + return { + tickets: response.tickets.map(mapListItem), + pagination: response.pagination + ? { ...response.pagination, totalTickets: Number(response.pagination.totalTickets) } + : undefined, + filters: response.filters, + }; +} + +function mapPublicReply(reply: PublicTicketReply) { + return { ...reply, created: timestampToIso(reply.created) }; +} + +function mapPublicNote(note: PublicTicketNote) { + return { ...note, date: timestampToIso(note.date) }; +} + +// Returns `any` to keep the legacy untyped contract; the public ticket page reads several +// fields the proto message does not model and feeds dates straight into `new Date(...)`. +function mapPublicTicketResponse(ticket: PublicTicketResponse): any { + return { + ...ticket, + _id: ticket.id, + created: timestampToIso(ticket.created), + date: timestampToIso(ticket.date), + replies: ticket.replies.map(mapPublicReply), + messages: ticket.messages.map(mapPublicReply), + notes: ticket.notes.map(mapPublicNote), + chatMessages: ticket.chatMessages.map((m) => ({ ...m, timestamp: timestampToIso(m.timestamp) })), + }; +} + +// Backend normalizeAttachments accepts either a url string or a {url} object; proto-JSON +// Struct values must be objects, so wrap raw url strings into {url} before encoding. +function toAttachmentStructs(attachments: unknown): JsonObject[] { + if (!Array.isArray(attachments)) return []; + return attachments.map((attachment) => + typeof attachment === 'string' ? { url: attachment } : (attachment as JsonObject), + ); +} + +export function useTickets(options?: { + page?: number; + limit?: number; + search?: string; + status?: string; + types?: string[]; + author?: string; + labels?: string[]; + assignees?: string[]; + sort?: string; +}) { + const { page = 1, limit = 10, search = '', status = '', types = [], author = '', labels = [], assignees = [], sort = 'newest' } = options || {}; + + return useQuery({ + queryKey: ['/v1/panel/tickets', { page, limit, search, status, types, author, labels, assignees, sort }], + queryFn: async () => { + const params = new URLSearchParams(); + params.append('page', page.toString()); + params.append('limit', limit.toString()); + if (search) params.append('search', search); + if (status) params.append('status', status); + if (types.length > 0) { + types.forEach(type => params.append('type', type)); + } + if (author) params.append('author', author); + if (labels.length > 0) { + labels.forEach(label => params.append('labels', label)); + } + if (assignees.length > 0) { + assignees.forEach(assignee => params.append('assignee', assignee)); + } + if (sort) params.append('sort', sort); + + const response = await protoFetch(PaginatedTicketsResponseSchema, `/v1/panel/tickets?${params.toString()}`); + return mapPaginatedTickets(response); + }, + staleTime: 30000, + refetchOnWindowFocus: true, + placeholderData: keepPreviousData, + }); +} + +export function useTicket(id: string) { + return useQuery({ + queryKey: ['/v1/public/tickets', id], + queryFn: async () => { + const tokenKey = `ticket_auth_${id}`; + const token = getCookie(tokenKey); + + const url = token + ? `/v1/public/tickets/${id}?token=${encodeURIComponent(token)}` + : `/v1/public/tickets/${id}`; + + const res = await apiFetch(url); + if (!res.ok) { + if (res.status === 404) { + return null; + } + if (res.status === 403) { + const data = await res.json().catch(() => ({ requiresVerification: true })); + if (data.requiresVerification) { + return { requiresVerification: true, emailHint: data.emailHint || '', ticketId: id }; + } + } + throw new Error('Failed to fetch ticket'); + } + return mapPublicTicketResponse(fromJson(PublicTicketResponseSchema, await res.json(), READ_OPTS)); + }, + enabled: !!id, + retry: false, + staleTime: 30000, + gcTime: 1000 * 60 * 10, + refetchOnWindowFocus: true + }); +} + +export function useCreateTicket() { + return useMutation({ + mutationFn: (ticketData: MessageInitShape) => + protoSend( + 'POST', + '/v1/panel/tickets', + CreateTicketRequestSchema, + create(CreateTicketRequestSchema, ticketData), + CreateTicketResponseSchema, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets'] }); + } + }); +} + +export function useUpdateTicket() { + return useMutation({ + mutationFn: async ({ id, data }: { id: string, data: any }) => { + const request = create(UpdateTicketRequestSchema, { + status: data.status, + locked: data.locked, + hidden: data.hidden, + tags: data.tags, + assignedTo: data.assignedTo, + data: data.data, + newReply: data.newReply + ? create(AddReplyRequestSchema, { + name: data.newReply.name, + content: data.newReply.content, + type: data.newReply.type, + staff: data.newReply.staff, + avatar: data.newReply.avatar, + attachments: toAttachmentStructs(data.newReply.attachments), + action: data.newReply.action, + creatorIdentifier: data.newReply.creatorIdentifier, + }) + : undefined, + newNote: data.newNote + ? create(AddNoteRequestSchema, { + text: data.newNote.text, + issuerName: data.newNote.issuerName, + issuerAvatar: data.newNote.issuerAvatar, + }) + : undefined, + }); + const response = await protoSend( + 'PATCH', + `/v1/panel/tickets/${id}`, + UpdateTicketRequestSchema, + request, + TicketResponseSchema, + ); + return mapTicketResponse(response); + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets', data._id] }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets'] }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets/counts'] }); + } + }); +} + +export function useAddTicketReply() { + return useMutation({ + mutationFn: ({ id, reply }: { id: string, reply: any }) => { + const tokenKey = `ticket_auth_${id}`; + const token = getCookie(tokenKey); + + const url = token + ? `/v1/public/tickets/${id}/replies?token=${encodeURIComponent(token)}` + : `/v1/public/tickets/${id}/replies`; + + const request = create(AddReplyRequestSchema, { + name: reply.name, + content: reply.content, + type: reply.type, + staff: !!reply.staff, + avatar: reply.avatar, + attachments: toAttachmentStructs(reply.attachments), + action: reply.action, + creatorIdentifier: reply.creatorIdentifier, + }); + + return protoSend('POST', url, AddReplyRequestSchema, request, AddTicketReplyResponseSchema); + }, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ['/v1/public/tickets', variables.id] }); + } + }); +} + +export function useSubmitTicketForm() { + return useMutation({ + mutationFn: async ({ id, formData }: { id: string, formData: any }) => { + const request = create(SubmitTicketFormRequestSchema, { + subject: formData.subject, + creatorEmail: formData.creatorEmail, + formData: formData.formData, + attachments: toAttachmentStructs(formData.attachments), + creatorIdentifier: formData.creatorIdentifier, + fieldLabels: formData.fieldLabels, + }); + + const res = await apiFetch(`/v1/public/tickets/${id}/submit`, { + method: 'POST', + body: toJson(SubmitTicketFormRequestSchema, request), + }); + + if (!res.ok) { + const errorPayload = await res.json().catch(() => null); + throw new Error(getApiErrorMessage(errorPayload, 'Failed to submit ticket form')); + } + + return fromJson(SubmitPublicTicketResponseSchema, await res.json(), READ_OPTS); + }, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ['/v1/public/tickets', variables.id] }); + } + }); +} + +export function useRequestTicketVerification() { + return useMutation({ + mutationFn: async (ticketId: string) => { + const res = await apiFetch(`/v1/public/tickets/${ticketId}/request-verification`, { + method: 'POST', + }); + + if (!res.ok) { + const error = await res.json().catch(() => ({})); + throw new Error(error.message || 'Failed to request verification'); + } + + return fromJson(PublicTicketVerificationRequestResponseSchema, await res.json(), READ_OPTS); + } + }); +} + +export function useVerifyTicketCode() { + return useMutation({ + mutationFn: async ({ ticketId, code }: { ticketId: string, code: string }) => { + const request = create(VerifyTicketCodeRequestSchema, { code }); + + const res = await apiFetch(`/v1/public/tickets/${ticketId}/verify`, { + method: 'POST', + body: toJson(VerifyTicketCodeRequestSchema, request), + }); + + if (!res.ok) { + const error = await res.json().catch(() => ({})); + throw new Error(error.message || 'Invalid or expired code'); + } + + return fromJson(PublicTicketVerificationResponseSchema, await res.json(), READ_OPTS); + } + }); +} + +export function usePanelTicket(id: string) { + return useQuery({ + queryKey: ['/v1/panel/tickets', id], + queryFn: async () => { + const response = await protoFetchOrNull(TicketResponseSchema, `/v1/panel/tickets/${id}`); + return response ? mapTicketResponse(response) : null; + }, + enabled: !!id, + staleTime: 30000, + gcTime: 1000 * 60 * 10, + refetchOnWindowFocus: true + }); +} + +export function usePlayerAllTickets(uuid: string) { + return useQuery({ + queryKey: ['/v1/panel/tickets/player', uuid], + queryFn: async () => { + const res = await apiFetch(`/v1/panel/tickets/player/${uuid}`); + if (!res.ok) { + if (res.status === 404) { + return []; + } + throw new Error('Failed to fetch player tickets'); + } + const data = await res.json(); + const items = Array.isArray(data) ? data : []; + return items.map((ticket: any) => { + const createdDate = ticket.created ? new Date(ticket.created) : null; + const created = createdDate && !isNaN(createdDate.getTime()) ? createdDate.toISOString() : null; + return { ...ticket, created }; + }); + }, + enabled: !!uuid, + staleTime: 30000, + refetchOnWindowFocus: true + }); +} + +export function useTicketStatusCounts(options?: { + search?: string; + types?: string[]; + author?: string; + labels?: string[]; + assignees?: string[]; +}) { + const { search = '', types = [], author = '', labels = [], assignees = [] } = options || {}; + + return useQuery({ + queryKey: ['/v1/panel/tickets/counts', { search, types, author, labels, assignees }], + queryFn: async (): Promise<{ open: number; closed: number }> => { + const params = new URLSearchParams(); + if (search) params.append('search', search); + if (types.length > 0) { + types.forEach(type => params.append('type', type)); + } + if (author) params.append('author', author); + if (labels.length > 0) { + labels.forEach(label => params.append('labels', label)); + } + if (assignees.length > 0) { + assignees.forEach(assignee => params.append('assignee', assignee)); + } + + const response = await protoFetch(TicketCountsResponseSchema, `/v1/panel/tickets/counts?${params.toString()}`); + return { open: Number(response.open), closed: Number(response.closed) }; + }, + staleTime: 30000, + refetchOnWindowFocus: true, + placeholderData: keepPreviousData, + }); +} + +export function useBulkUpdateTickets() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (request: { + ticketIds: string[]; + locked?: boolean; + addLabels?: string[]; + removeLabels?: string[]; + assignTo?: string; + }) => + protoSend( + 'POST', + '/v1/panel/tickets/bulk', + BulkTicketUpdateRequestSchema, + create(BulkTicketUpdateRequestSchema, request), + BulkTicketUpdateResponseSchema, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets'] }); + queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets/counts'] }); + }, + }); +} diff --git a/client/src/hooks/use-auth.tsx b/client/src/hooks/use-auth.tsx index 4b06594..7181b7b 100644 --- a/client/src/hooks/use-auth.tsx +++ b/client/src/hooks/use-auth.tsx @@ -1,4 +1,4 @@ -import { createContext, ReactNode, useContext, useState, useEffect } from "react"; +import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useLocation } from "wouter"; import { useToast } from "@modl-gg/shared-web/hooks/use-toast"; import { getApiUrl, getCurrentDomain } from "@/lib/api"; @@ -66,7 +66,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); - const fetchAuthenticatedUser = async (): Promise => { + const fetchAuthenticatedUser = useCallback(async (): Promise => { const response = await authFetch('/v1/panel/auth/me'); if (!response.ok) { return null; @@ -74,12 +74,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { const userData = await response.json(); return mapUserFromMeResponse(userData); - }; + }, []); - const refreshUser = async () => { + const refreshUser = useCallback(async () => { const authenticatedUser = await fetchAuthenticatedUser(); setUser(authenticatedUser); - }; + }, [fetchAuthenticatedUser]); useEffect(() => { const checkSession = async () => { @@ -96,16 +96,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; checkSession(); - }, []); + }, [fetchAuthenticatedUser]); useEffect(() => { const lang = user?.language || 'en'; + const dateFormat = user?.dateFormat || 'MM/DD/YYYY'; setDateLocale(lang); - setDateFormat(user?.dateFormat || 'MM/DD/YYYY'); - i18n.changeLanguage(lang); + setDateFormat(dateFormat); + if (lang !== i18n.language) { + i18n.changeLanguage(lang); + } }, [user?.language, user?.dateFormat]); - const requestEmailVerification = async (email: string): Promise => { + const requestEmailVerification = useCallback(async (email: string): Promise => { try { const response = await authFetch('/v1/panel/auth/send-email-code', { method: 'POST', @@ -144,9 +147,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); return undefined; } - }; + }, [toast]); - const login = async (email: string, code: string): Promise => { + const login = useCallback(async (email: string, code: string): Promise => { setIsLoading(true); try { const response = await authFetch('/v1/panel/auth/verify-email-code', { @@ -207,9 +210,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { setIsLoading(false); return false; } - }; + }, [fetchAuthenticatedUser, toast]); - const checkPasskeyOptions = async (email: string): Promise => { + const checkPasskeyOptions = useCallback(async (email: string): Promise => { try { const response = await authFetch('/v1/panel/auth/webauthn/login/options', { method: 'POST', @@ -228,9 +231,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { } catch { return { hasPasskeys: false }; } - }; + }, []); - const loginWithPasskey = async (challengeId: string, optionsJson: any): Promise => { + const loginWithPasskey = useCallback(async (challengeId: string, optionsJson: any): Promise => { setIsLoading(true); try { const optionsJSON = optionsJson?.publicKey ?? optionsJson; @@ -289,9 +292,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { setIsLoading(false); return false; } - }; + }, [fetchAuthenticatedUser, toast]); - const loginWithDiscoverablePasskey = async (): Promise => { + const loginWithDiscoverablePasskey = useCallback(async (): Promise => { setIsLoading(true); try { const startRes = await authFetch('/v1/panel/auth/webauthn/login/start', { @@ -364,9 +367,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { setIsLoading(false); return false; } - }; + }, [fetchAuthenticatedUser, toast]); - const logout = async () => { + const logout = useCallback(async () => { setIsLoading(true); let shouldRedirectToAuth = false; @@ -424,22 +427,32 @@ export function AuthProvider({ children }: { children: ReactNode }) { navigate('/auth'); } } - }; + }, [fetchAuthenticatedUser, navigate, toast]); + + const contextValue = useMemo(() => ({ + user, + isLoading, + refreshUser, + login, + logout, + requestEmailVerification, + checkPasskeyOptions, + loginWithPasskey, + loginWithDiscoverablePasskey, + }), [ + user, + isLoading, + refreshUser, + login, + logout, + requestEmailVerification, + checkPasskeyOptions, + loginWithPasskey, + loginWithDiscoverablePasskey, + ]); return ( - + {children} ); diff --git a/client/src/hooks/use-data.tsx b/client/src/hooks/use-data.tsx index 15d9ae4..7af75ae 100644 --- a/client/src/hooks/use-data.tsx +++ b/client/src/hooks/use-data.tsx @@ -1,1454 +1,11 @@ -import React from 'react'; -import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; -import { queryClient } from '../lib/queryClient'; -import { useAuth } from './use-auth'; -import { apiFetch } from '@/lib/api'; -import { isPublicPage } from '@/utils/routes'; -import { getApiErrorMessage } from '@/utils/email-validation'; - -type SettingsEnvelope = { - data: T; - _meta?: { - version: number; - updatedAt?: string | null; - } | null; -}; - -function toSettingsEnvelope(payload: any): SettingsEnvelope { - if (payload && typeof payload === 'object' && 'data' in payload) { - return payload as SettingsEnvelope; - } - return { - data: payload as T, - _meta: { - version: 0, - updatedAt: null, - }, - }; -} - -export function usePlayer(uuid: string) { - return useQuery({ - queryKey: ['/v1/panel/players', uuid], - queryFn: async () => { - const res = await apiFetch(`/v1/panel/players/${uuid}`); - if (!res.ok) { - if (res.status === 404) { - return null; - } - throw new Error('Failed to fetch player'); - } - return res.json(); - }, - enabled: !!uuid, - staleTime: 1000, - refetchOnWindowFocus: true - }); -} - -export function useLinkedAccounts(uuid: string) { - return useQuery({ - queryKey: ['/v1/panel/players/linked', uuid], - queryFn: async () => { - const res = await apiFetch(`/v1/panel/players/${uuid}/linked`); - if (!res.ok) { - if (res.status === 404) { - return { linkedAccounts: [] }; - } - throw new Error('Failed to fetch linked accounts'); - } - return res.json(); - }, - enabled: !!uuid, - staleTime: 1000, - refetchOnWindowFocus: true - }); -} - -export function useFindLinkedAccounts() { - return useMutation({ - mutationFn: async (minecraftUuid: string) => { - const res = await apiFetch(`/v1/panel/players/${minecraftUuid}/find-linked`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - if (!res.ok) { - throw new Error('Failed to trigger linked account search'); - } - - return res.json(); - }, - onSuccess: (data, minecraftUuid) => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/players/linked', minecraftUuid] }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/players', minecraftUuid] }); - } - }); -} - -export function useTickets(options?: { - page?: number; - limit?: number; - search?: string; - status?: string; - types?: string[]; - author?: string; - labels?: string[]; - assignees?: string[]; - sort?: string; -}) { - const { page = 1, limit = 10, search = '', status = '', types = [], author = '', labels = [], assignees = [], sort = 'newest' } = options || {}; - - return useQuery({ - queryKey: ['/v1/panel/tickets', { page, limit, search, status, types, author, labels, assignees, sort }], - queryFn: async () => { - const params = new URLSearchParams(); - params.append('page', page.toString()); - params.append('limit', limit.toString()); - if (search) params.append('search', search); - if (status) params.append('status', status); - if (types.length > 0) { - types.forEach(type => params.append('type', type)); - } - if (author) params.append('author', author); - if (labels.length > 0) { - labels.forEach(label => params.append('labels', label)); - } - if (assignees.length > 0) { - assignees.forEach(assignee => params.append('assignee', assignee)); - } - if (sort) params.append('sort', sort); - - const res = await apiFetch(`/v1/panel/tickets?${params.toString()}`); - if (!res.ok) { - throw new Error('Failed to fetch tickets'); - } - return res.json(); - }, - staleTime: 30000, - refetchOnWindowFocus: true, - placeholderData: keepPreviousData, - }); -} - -export function useTicket(id: string) { - return useQuery({ - queryKey: ['/v1/public/tickets', id], - queryFn: async () => { - const tokenKey = `ticket_auth_${id}`; - const token = getCookie(tokenKey); - - const url = token - ? `/v1/public/tickets/${id}?token=${encodeURIComponent(token)}` - : `/v1/public/tickets/${id}`; - - const res = await apiFetch(url); - if (!res.ok) { - if (res.status === 404) { - return null; - } - if (res.status === 403) { - const data = await res.json().catch(() => ({ requiresVerification: true })); - if (data.requiresVerification) { - return { requiresVerification: true, emailHint: data.emailHint || '', ticketId: id }; - } - } - throw new Error('Failed to fetch ticket'); - } - return res.json(); - }, - enabled: !!id, - retry: false, - staleTime: 30000, - gcTime: 60000, - refetchOnWindowFocus: true - }); -} - -export function setCookie(name: string, value: string, days: number) { - const expires = new Date(Date.now() + days * 864e5).toUTCString(); - document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; -} - -function getCookie(name: string): string | null { - const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); - return match ? decodeURIComponent(match[2]) : null; -} - -export function useCreateTicket() { - return useMutation({ - mutationFn: async (ticketData: any) => { - const res = await apiFetch('/v1/panel/tickets', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(ticketData) - }); - - if (!res.ok) { - throw new Error('Failed to create ticket'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets'] }); - } - }); -} - -export function useUpdateTicket() { - return useMutation({ - mutationFn: async ({ id, data }: { id: string, data: any }) => { - const res = await apiFetch(`/v1/panel/tickets/${id}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); - - if (!res.ok) { - throw new Error('Failed to update ticket'); - } - - return res.json(); - }, - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets', data._id] }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets'] }); - } - }); -} - -export function useAddTicketReply() { - return useMutation({ - mutationFn: async ({ id, reply }: { id: string, reply: any }) => { - const tokenKey = `ticket_auth_${id}`; - const token = getCookie(tokenKey); - - const url = token - ? `/v1/public/tickets/${id}/replies?token=${encodeURIComponent(token)}` - : `/v1/public/tickets/${id}/replies`; - - const res = await apiFetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(reply) - }); - - if (!res.ok) { - throw new Error('Failed to add reply'); - } - - return res.json(); - }, - onSuccess: (data, variables) => { - queryClient.invalidateQueries({ queryKey: ['/v1/public/tickets', variables.id] }); - } - }); -} - -export function useSubmitTicketForm() { - return useMutation({ - mutationFn: async ({ id, formData }: { id: string, formData: any }) => { - const res = await apiFetch(`/v1/public/tickets/${id}/submit`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(formData) - }); - - if (!res.ok) { - const errorPayload = await res.json().catch(() => null); - throw new Error(getApiErrorMessage(errorPayload, 'Failed to submit ticket form')); - } - - return res.json(); - }, - onSuccess: (data, variables) => { - queryClient.invalidateQueries({ queryKey: ['/v1/public/tickets', variables.id] }); - } - }); -} - -export function useRequestTicketVerification() { - return useMutation({ - mutationFn: async (ticketId: string) => { - const res = await apiFetch(`/v1/public/tickets/${ticketId}/request-verification`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - if (!res.ok) { - const error = await res.json().catch(() => ({})); - throw new Error(error.message || 'Failed to request verification'); - } - - return res.json(); - } - }); -} - -export function useVerifyTicketCode() { - return useMutation({ - mutationFn: async ({ ticketId, code }: { ticketId: string, code: string }) => { - const res = await apiFetch(`/v1/public/tickets/${ticketId}/verify`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ code }) - }); - - if (!res.ok) { - const error = await res.json().catch(() => ({})); - throw new Error(error.message || 'Invalid or expired code'); - } - - return res.json(); - } - }); -} - -export function useAppeals() { - return useQuery({ - queryKey: ['/v1/panel/appeals'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/appeals'); - if (!res.ok) { - throw new Error('Failed to fetch appeals'); - } - return res.json(); - } - }); -} - -export function useAppealsByPunishment(punishmentId: string) { - return useQuery({ - queryKey: ['/v1/panel/appeals/punishment', punishmentId], - queryFn: async () => { - const res = await apiFetch(`/v1/panel/appeals/punishment/${punishmentId}`); - if (!res.ok) { - if (res.status === 404) { - return []; - } - throw new Error('Failed to fetch appeals'); - } - return res.json(); - }, - enabled: !!punishmentId - }); -} - -export function useCreateAppeal() { - return useMutation({ - mutationFn: async (appealData: any) => { - const res = await apiFetch('/v1/public/appeals', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(appealData) - }); - - if (!res.ok) { - throw new Error('Failed to create appeal'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/appeals'] }); - } - }); -} - -export function useStaff() { - return useQuery({ - queryKey: ['/v1/panel/staff'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/staff'); - if (!res.ok) { - throw new Error('Failed to fetch staff'); - } - return res.json(); - }, - staleTime: 1000 * 60, - }); -} - -export function useLogs() { - return useQuery({ - queryKey: ['/v1/panel/logs'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/logs'); - if (!res.ok) { - throw new Error('Failed to fetch logs'); - } - return res.json(); - } - }); -} - -export function usePunishmentTypes() { - return useQuery({ - queryKey: ['/v1/panel/settings/punishment-types'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/settings/punishment-types'); - if (!res.ok) { - throw new Error('Failed to fetch punishment types'); - } - return res.json(); - }, - staleTime: 1000 * 60 * 5, - refetchOnWindowFocus: false - }); -} - -export function useSettings() { - return useQuery({ - queryKey: ['/v1/settings'], - queryFn: async () => { - const isPublic = isPublicPage(); - - try { - if (isPublic) { - const publicRes = await apiFetch('/v1/public/settings'); - - if (publicRes.ok) { - const publicData = await publicRes.json(); - return { - settings: { - general: { - serverDisplayName: publicData.serverDisplayName, - panelIconUrl: publicData.panelIconUrl, - homepageIconUrl: publicData.homepageIconUrl - }, - ticketForms: publicData.ticketForms || {} - } - }; - } - } else { - const [res, webhookRes] = await Promise.all([ - apiFetch('/v1/panel/settings/general'), - apiFetch('/v1/panel/settings/webhooks') - ]); - - if (res.ok) { - const generalEnvelope = toSettingsEnvelope(await res.json()); - const general = generalEnvelope.data || {}; - let webhookSettings = null; - - if (webhookRes.ok) { - webhookSettings = await webhookRes.json(); - } - - return { - settings: { - ...general, - general, - generalMeta: generalEnvelope._meta || null, - webhookSettings - } - }; - } - - if (res.status === 401) { - const publicRes = await apiFetch('/v1/public/settings'); - - if (publicRes.ok) { - const publicData = await publicRes.json(); - return { - settings: { - general: { - serverDisplayName: publicData.serverDisplayName, - panelIconUrl: publicData.panelIconUrl, - homepageIconUrl: publicData.homepageIconUrl - }, - ticketForms: publicData.ticketForms || {} - } - }; - } - } - } - - throw new Error('Failed to fetch settings from all available endpoints'); - } catch (error) { - try { - const fallbackUrl = isPublic ? '/v1/panel/settings/general' : '/v1/public/settings'; - const fallbackRes = await apiFetch(fallbackUrl); - - if (fallbackRes.ok) { - if (isPublic) { - const payload = await fallbackRes.json(); - if (payload && typeof payload === 'object' && 'settings' in payload) { - return { settings: (payload as any).settings || {} }; - } - - const generalEnvelope = toSettingsEnvelope(payload); - const general = generalEnvelope.data || {}; - return { - settings: { - ...general, - general, - generalMeta: generalEnvelope._meta || null, - } - }; - } else { - const publicData = await fallbackRes.json(); - return { - settings: { - general: { - serverDisplayName: publicData.serverDisplayName, - panelIconUrl: publicData.panelIconUrl, - homepageIconUrl: publicData.homepageIconUrl - }, - ticketForms: publicData.ticketForms || {} - } - }; - } - } - } catch (fallbackError) { - return { - settings: { - general: { - serverDisplayName: 'modl', - panelIconUrl: null, - homepageIconUrl: null - }, - ticketForms: {} - } - }; - } - - throw error; - } - }, - staleTime: 300000, - refetchOnWindowFocus: false, - gcTime: 1000 * 60 * 5, - refetchInterval: false, - refetchOnReconnect: false - }); -} - -export function useTicketFormSettings() { - return useQuery | null>({ - queryKey: ['/v1/panel/settings/ticket-forms'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/settings/ticket-forms'); - if (!res.ok) { - if (res.status === 401 || res.status === 403) { - return null; - } - throw new Error('Failed to fetch ticket form settings'); - } - return toSettingsEnvelope(await res.json()); - }, - staleTime: 1000 * 60 * 5, - refetchOnWindowFocus: false - }); -} - -export function useQuickResponses() { - return useQuery | null>({ - queryKey: ['/v1/panel/settings/quick-responses'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/settings/quick-responses'); - if (!res.ok) { - if (res.status === 401 || res.status === 403) { - return null; - } - throw new Error('Failed to fetch quick responses'); - } - return toSettingsEnvelope(await res.json()); - }, - staleTime: 1000 * 60 * 5, - refetchOnWindowFocus: false - }); -} - -export function useStatusThresholds() { - return useQuery | null>({ - queryKey: ['/v1/panel/settings/status-thresholds'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/settings/status-thresholds'); - if (!res.ok) { - if (res.status === 401 || res.status === 403) { - return null; - } - throw new Error('Failed to fetch status thresholds'); - } - return toSettingsEnvelope(await res.json()); - }, - staleTime: 1000 * 60 * 5, - refetchOnWindowFocus: false - }); -} - -export function useTicketLabelSettings() { - return useQuery | null>({ - queryKey: ['/v1/panel/settings/ticket-labels'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/settings/ticket-labels'); - if (!res.ok) { - if (res.status === 401 || res.status === 403) { - return null; - } - throw new Error('Failed to fetch ticket label settings'); - } - return toSettingsEnvelope(await res.json()); - }, - staleTime: 1000 * 60 * 5, - refetchOnWindowFocus: false - }); -} - -export function useStats() { - return useQuery({ - queryKey: ['/v1/panel/stats'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/stats'); - if (!res.ok) { - throw new Error('Failed to fetch stats'); - } - return res.json(); - }, - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: true, - }); -} - -export function useBillingStatus() { - return useQuery({ - queryKey: ['/v1/panel/billing/status'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/billing/status'); - if (!res.ok) { - throw new Error('Failed to fetch billing status'); - } - return res.json(); - }, - staleTime: 1000 * 60 * 5, - }); -} - -export function useCancelSubscription() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async () => { - const res = await apiFetch('/v1/panel/billing/cancel', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - if (!res.ok) { - const errorText = await res.text(); - throw new Error(errorText || 'Failed to cancel subscription'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/status'] }); - }, - }); -} - -export function useUsageData() { - return useQuery({ - queryKey: ['/v1/panel/billing/usage'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/billing/usage'); - if (!res.ok) { - throw new Error('Failed to fetch usage data'); - } - return res.json(); - }, - staleTime: 1000 * 60 * 5, - }); -} - -export function useUpdateUsageBillingSettings() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ enabled }: { enabled: boolean }) => { - const res = await apiFetch('/v1/panel/billing/usage-settings', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ enabled }) - }); - - if (!res.ok) { - const errorText = await res.text(); - throw new Error(errorText || 'Failed to update usage billing settings'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/usage'] }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/status'] }); - }, - }); -} - -export function useResubscribe() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async () => { - const res = await apiFetch('/v1/panel/billing/resubscribe', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - if (!res.ok) { - const errorText = await res.text(); - throw new Error(errorText || 'Failed to resubscribe'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/status'] }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/usage'] }); - }, - }); -} - -export function useApplyPunishment() { - return useMutation({ - mutationFn: async ({ uuid, punishmentData }: { uuid: string, punishmentData: any }) => { - const res = await apiFetch(`/v1/panel/players/${uuid}/punishments`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(punishmentData) - }); - - if (!res.ok) { - let errorMessage = `Failed to apply punishment: ${res.status} ${res.statusText}`; - - try { - const errorData = await res.json(); - if (res.status === 403) { - errorMessage = `Permission denied: ${errorData.error || 'You do not have permission to apply this punishment type'}`; - if (errorData.punishmentType) { - errorMessage += ` (${errorData.punishmentType})`; - } - } else { - errorMessage = errorData.error || errorData.message || errorMessage; - } - } catch { - const errorText = await res.text(); - if (res.status === 403) { - errorMessage = 'Permission denied: You do not have permission to apply this punishment type'; - } else { - errorMessage = errorText || errorMessage; - } - } - - throw new Error(errorMessage); - } - - return res.json(); - }, - onSuccess: (data, variables) => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/players', variables.uuid] }); - }, - onError: (error) => { - console.error('Error applying punishment:', error); - } - }); -} - -export function usePanelTicket(id: string) { - return useQuery({ - queryKey: ['/v1/panel/tickets', id], - queryFn: async () => { - const res = await apiFetch(`/v1/panel/tickets/${id}`); - - if (!res.ok) { - const errorText = await res.text(); - - if (res.status === 404) { - return null; - } - throw new Error(`Failed to fetch ticket: ${res.status} ${errorText}`); - } - - return res.json(); - }, - enabled: !!id, - staleTime: 30000, - gcTime: 60000, - refetchOnWindowFocus: true - }); -} - -export function usePlayerAllTickets(uuid: string) { - return useQuery({ - queryKey: ['/v1/panel/tickets/player', uuid], - queryFn: async () => { - const res = await apiFetch(`/v1/panel/tickets/player/${uuid}`); - if (!res.ok) { - if (res.status === 404) { - return []; - } - throw new Error('Failed to fetch player tickets'); - } - return res.json(); - }, - enabled: !!uuid, - staleTime: 30000, - refetchOnWindowFocus: true - }); -} - -export function useModifyPunishment() { - const { user } = useAuth(); - - return useMutation({ - mutationFn: async ({ - uuid, - punishmentId, - modificationType, - reason, - newDuration, - appealTicketId - }: { - uuid: string, - punishmentId: string, - modificationType: string, - reason: string, - newDuration?: { value: number; unit: string }, - appealTicketId?: string - }) => { - const body: any = { - type: modificationType, - issuerName: user?.username || 'Unknown User', - issuerId: user?.id, - reason: reason - }; - - if (appealTicketId) { - body.appealTicketId = appealTicketId; - } - - if ((modificationType === 'MANUAL_DURATION_CHANGE' || modificationType === 'APPEAL_DURATION_CHANGE') && newDuration) { - const multipliers = { - 'seconds': 1000, - 'minutes': 60 * 1000, - 'hours': 60 * 60 * 1000, - 'days': 24 * 60 * 60 * 1000, - 'weeks': 7 * 24 * 60 * 60 * 1000, - 'months': 30 * 24 * 60 * 60 * 1000 - }; - - const durationMs = newDuration.value * (multipliers[newDuration.unit as keyof typeof multipliers] || 0); - body.effectiveDuration = durationMs; - } - - const res = await apiFetch(`/v1/panel/players/${uuid}/punishments/${punishmentId}/modifications`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - }); - - if (!res.ok) { - throw new Error(`Failed to modify punishment: ${res.status} ${res.statusText}`); - } - - return res.json(); - }, - onSuccess: (data, variables) => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/players', variables.uuid] }); - }, - onError: (error) => { - console.error('Error modifying punishment:', error); - } - }); -} - -export function useAddPunishmentNote() { - const { user } = useAuth(); - - return useMutation({ - mutationFn: async ({ - uuid, - punishmentId, - noteText - }: { - uuid: string, - punishmentId: string, - noteText: string - }) => { - const res = await apiFetch(`/v1/panel/players/${uuid}/punishments/${punishmentId}/notes`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - text: noteText, - issuerName: user?.username || 'Unknown User', - issuerId: user?.id - }) - }); - - if (!res.ok) { - throw new Error(`Failed to add punishment note: ${res.status} ${res.statusText}`); - } - - return res.json(); - }, - onSuccess: (data, variables) => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/players', variables.uuid] }); - }, - onError: (error) => { - console.error('Error adding punishment note:', error); - } - }); -} - -export function useModifyPunishmentTickets() { - const { user } = useAuth(); - - return useMutation({ - mutationFn: async ({ - uuid, - punishmentId, - addTicketIds, - removeTicketIds, - modifyAssociatedTickets - }: { - uuid: string, - punishmentId: string, - addTicketIds?: string[], - removeTicketIds?: string[], - modifyAssociatedTickets: boolean - }) => { - const res = await apiFetch(`/v1/panel/players/${uuid}/punishments/${punishmentId}/tickets`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - addTicketIds, - removeTicketIds, - modifyAssociatedTickets, - issuerName: user?.username || 'Unknown User', - issuerId: user?.id - }) - }); - - if (!res.ok) { - throw new Error(`Failed to modify punishment tickets: ${res.status} ${res.statusText}`); - } - - return res.json(); - }, - onSuccess: (data, variables) => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/players', variables.uuid] }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets'] }); - }, - onError: (error) => { - console.error('Error modifying punishment tickets:', error); - } - }); -} - -export function useLinkedBansForPunishment(punishmentId: string | null) { - return useQuery({ - queryKey: ['/v1/panel/players/punishments', punishmentId, 'linked-bans'], - queryFn: async () => { - if (!punishmentId) return []; - const res = await apiFetch(`/v1/panel/players/punishments/${punishmentId}/linked-bans`); - if (!res.ok) { - if (res.status === 404) return []; - throw new Error('Failed to fetch linked bans'); - } - return res.json(); - }, - enabled: !!punishmentId, - staleTime: 30000, - }); -} - -export function useRoles() { - return useQuery({ - queryKey: ['/v1/panel/roles'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/roles'); - if (!res.ok) { - throw new Error('Failed to fetch roles'); - } - return res.json(); - } - }); -} - -export function usePermissions() { - return useQuery({ - queryKey: ['/v1/panel/roles/permissions'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/roles/permissions'); - if (!res.ok) { - throw new Error('Failed to fetch permissions'); - } - return res.json(); - } - }); -} - -export function useCreateRole() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (roleData: { name: string; description: string; permissions: string[] }) => { - const res = await apiFetch('/v1/panel/roles', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(roleData), - }); - - if (!res.ok) { - throw new Error(`Failed to create role: ${res.status} ${res.statusText}`); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/roles'] }); - }, - }); -} - -export function useUpdateRole() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ id, ...roleData }: { id: string; name: string; description: string; permissions: string[] }) => { - const res = await apiFetch(`/v1/panel/roles/${id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(roleData), - }); - - if (!res.ok) { - throw new Error(`Failed to update role: ${res.status} ${res.statusText}`); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/roles'] }); - }, - }); -} - -export function useDeleteRole() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (roleId: string) => { - const res = await apiFetch(`/v1/panel/roles/${roleId}`, { - method: 'DELETE', - }); - - if (!res.ok) { - throw new Error(`Failed to delete role: ${res.status} ${res.statusText}`); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/roles'] }); - }, - }); -} - -export function useAssignMinecraftPlayer() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - username, - minecraftUuid, - minecraftUsername - }: { - username: string; - minecraftUuid?: string; - minecraftUsername?: string; - }) => { - const res = await apiFetch(`/v1/panel/staff/${username}/minecraft-player`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ minecraftUuid, minecraftUsername }), - }); - - if (!res.ok) { - throw new Error(`Failed to assign Minecraft player: ${res.status} ${res.statusText}`); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/staff'] }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/staff/available-players'] }); - }, - }); -} - -export function useDashboardMetrics(period: string = '7d') { - return useQuery({ - queryKey: ['/v1/panel/dashboard/metrics', period], - queryFn: async () => { - const res = await apiFetch(`/v1/panel/dashboard/metrics?period=${period}`); - if (!res.ok) { - throw new Error('Failed to fetch dashboard metrics'); - } - return res.json(); - }, - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: true, - }); -} - -export function useRecentTickets(limit: number = 5) { - return useQuery({ - queryKey: ['/v1/panel/dashboard/recent-tickets', limit], - queryFn: async () => { - const res = await apiFetch(`/v1/panel/dashboard/recent-tickets?limit=${limit}`); - if (!res.ok) { - throw new Error('Failed to fetch recent tickets'); - } - return res.json(); - }, - staleTime: 2 * 60 * 1000, - refetchOnWindowFocus: false, - }); -} - -export function useRecentPunishments(limit: number = 10) { - return useQuery({ - queryKey: ['/v1/panel/dashboard/recent-punishments', limit], - queryFn: async () => { - const res = await apiFetch(`/v1/panel/dashboard/recent-punishments?limit=${limit}`); - if (!res.ok) { - throw new Error('Failed to fetch recent punishments'); - } - return res.json(); - }, - staleTime: 2 * 60 * 1000, - refetchOnWindowFocus: false, - }); -} - -export function useTicketSubscriptionUpdates(limit: number = 10) { - return useQuery({ - queryKey: ['/v1/panel/ticket-subscriptions/updates', limit], - queryFn: async () => { - const res = await apiFetch(`/v1/panel/ticket-subscriptions/updates?limit=${limit}`); - if (!res.ok) { - throw new Error('Failed to fetch ticket subscription updates'); - } - return res.json(); - }, - staleTime: 30 * 1000, - refetchOnWindowFocus: true, - }); -} - -export function useUnsubscribeFromTicket() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (ticketId: string) => { - const res = await apiFetch(`/v1/panel/ticket-subscriptions/${ticketId}`, { - method: 'DELETE', - }); - - if (!res.ok) { - throw new Error('Failed to unsubscribe from ticket'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions'] }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions/updates'] }); - }, - }); -} - -export function useMarkSubscriptionUpdateAsRead() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (updateId: string) => { - const res = await apiFetch(`/v1/panel/ticket-subscriptions/updates/${encodeURIComponent(updateId)}/read`, { - method: 'POST', - }); - - if (!res.ok) { - throw new Error('Failed to mark update as read'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions/updates'] }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions/assigned-updates'] }); - }, - }); -} - -export function useMarkTicketAsRead() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (ticketId: string) => { - const res = await apiFetch(`/v1/panel/ticket-subscriptions/tickets/${encodeURIComponent(ticketId)}/read`, { - method: 'POST', - }); - - if (!res.ok) { - throw new Error('Failed to mark ticket as read'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions/updates'] }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/ticket-subscriptions/assigned-updates'] }); - }, - }); -} - -export function useTicketStatusCounts(options?: { - search?: string; - types?: string[]; - author?: string; - labels?: string[]; - assignees?: string[]; -}) { - const { search = '', types = [], author = '', labels = [], assignees = [] } = options || {}; - - return useQuery({ - queryKey: ['/v1/panel/tickets/counts', { search, types, author, labels, assignees }], - queryFn: async () => { - const params = new URLSearchParams(); - if (search) params.append('search', search); - if (types.length > 0) { - types.forEach(type => params.append('type', type)); - } - if (author) params.append('author', author); - if (labels.length > 0) { - labels.forEach(label => params.append('labels', label)); - } - if (assignees.length > 0) { - assignees.forEach(assignee => params.append('assignee', assignee)); - } - - const res = await apiFetch(`/v1/panel/tickets/counts?${params.toString()}`); - if (!res.ok) { - throw new Error('Failed to fetch ticket counts'); - } - return res.json() as Promise<{ open: number; closed: number }>; - }, - staleTime: 30000, - refetchOnWindowFocus: true - }); -} - -export function useBulkUpdateTickets() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (request: { - ticketIds: string[]; - locked?: boolean; - addLabels?: string[]; - removeLabels?: string[]; - assignTo?: string; - }) => { - const res = await apiFetch('/v1/panel/tickets/bulk', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request), - }); - - if (!res.ok) { - throw new Error('Failed to bulk update tickets'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets'] }); - queryClient.invalidateQueries({ queryKey: ['/v1/panel/tickets/counts'] }); - }, - }); -} - -export function useLabels() { - return useQuery({ - queryKey: ['/v1/panel/settings/ticket-labels', 'labels'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/settings/ticket-labels'); - if (!res.ok) { - if (res.status === 401 || res.status === 403) { - return []; - } - throw new Error('Failed to fetch labels'); - } - const envelope = toSettingsEnvelope(await res.json()); - return envelope?.data?.labels || []; - }, - staleTime: 1000 * 60 * 5, - refetchOnWindowFocus: false - }); -} - -export function useAssignedTicketUpdates(limit: number = 10) { - return useQuery({ - queryKey: ['/v1/panel/ticket-subscriptions/assigned-updates', limit], - queryFn: async () => { - const res = await apiFetch(`/v1/panel/ticket-subscriptions/assigned-updates?limit=${limit}`); - if (!res.ok) { - throw new Error('Failed to fetch assigned ticket updates'); - } - return res.json(); - }, - staleTime: 30 * 1000, - refetchOnWindowFocus: false, - }); -} - -export function usePlayerSearch(searchQuery: string, debounceMs: number = 300) { - const [debouncedQuery, setDebouncedQuery] = React.useState(searchQuery); - - React.useEffect(() => { - const timer = setTimeout(() => { - setDebouncedQuery(searchQuery); - }, debounceMs); - - return () => clearTimeout(timer); - }, [searchQuery, debounceMs]); - - return useQuery({ - queryKey: ['player-search', debouncedQuery], - queryFn: async () => { - if (!debouncedQuery || debouncedQuery.trim().length < 2) { - return []; - } - const res = await apiFetch(`/v1/panel/players?search=${encodeURIComponent(debouncedQuery.trim())}`); - if (!res.ok) { - throw new Error('Failed to search players'); - } - return res.json(); - }, - enabled: debouncedQuery.trim().length >= 2, - staleTime: 1000 * 60, - }); -} - -export function useMigrationStatus() { - return useQuery({ - queryKey: ['/v1/panel/migration/status'], - queryFn: async () => { - const res = await apiFetch('/v1/panel/migration/status'); - if (!res.ok) { - throw new Error('Failed to fetch migration status'); - } - return res.json(); - }, - refetchInterval: (query) => { - const data = query.state.data; - const currentMigration = data?.currentMigration; - const isActive = currentMigration && - currentMigration.status !== 'completed' && - currentMigration.status !== 'failed'; - - return isActive ? 5000 : 30000; - } - }); -} - -export function useStartMigration() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ migrationType }: { migrationType: string }) => { - const res = await apiFetch('/v1/panel/migration/start', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ migrationType }) - }); - - if (!res.ok) { - const errorData = await res.json(); - throw new Error(errorData.error || 'Failed to start migration'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/migration/status'] }); - } - }); -} - -export function useCancelMigration() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async () => { - const res = await apiFetch('/v1/panel/migration/cancel', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - if (!res.ok) { - const errorData = await res.json(); - throw new Error(errorData.error || 'Failed to cancel migration'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/v1/panel/migration/status'] }); - } - }); -} +export * from './data/tickets'; +export * from './data/ticket-subscriptions'; +export * from './data/players'; +export * from './data/punishments'; +export * from './data/staff'; +export * from './data/settings'; +export * from './data/billing'; +export * from './data/dashboard'; +export * from './data/appeals'; +export * from './data/audit'; +export * from './data/migration'; diff --git a/client/src/hooks/use-media-upload.tsx b/client/src/hooks/use-media-upload.tsx index 29045fd..e32187a 100644 --- a/client/src/hooks/use-media-upload.tsx +++ b/client/src/hooks/use-media-upload.tsx @@ -301,7 +301,13 @@ export function useMediaUpload() { }; const deleteMedia = async (key: string): Promise => { - const response = await apiFetch(`/v1/panel/media/${encodeURIComponent(key)}`, { + // S3 object keys are multi-segment ({databaseName}/{type}/...). encodeURIComponent on the + // whole key turns every '/' into %2F, which the backend's {*key} path-capture route + Tomcat + // (encoded-slash REJECT by default) refuse. Encode each segment individually so the literal + // '/' separators survive for the catch-all capture. Use getEndpointPrefix() for parity with + // the rest of the hook (panel vs public) instead of hardcoding the panel prefix. + const encodedKey = key.split('/').map(encodeURIComponent).join('/'); + const response = await apiFetch(`${getEndpointPrefix()}/${encodedKey}`, { method: 'DELETE', }); diff --git a/client/src/hooks/use-permissions.tsx b/client/src/hooks/use-permissions.tsx index d28a7a6..0c742e4 100644 --- a/client/src/hooks/use-permissions.tsx +++ b/client/src/hooks/use-permissions.tsx @@ -80,7 +80,8 @@ export function usePermissions() { if (response.status === 401) return []; throw new Error('Failed to fetch permissions'); } - return response.json(); + const data = await response.json(); + return Array.isArray(data) ? data : (data?.permissions ?? []); } catch { return null; } @@ -153,7 +154,7 @@ export function usePermissions() { return permissions.some(permission => hasPermission(permission)); }, [user, hasPermission]); - const canAccessSettingsTab = (tabName: keyof typeof SETTINGS_PERMISSIONS): boolean => { + const canAccessSettingsTab = useCallback((tabName: keyof typeof SETTINGS_PERMISSIONS): boolean => { if (!user) return false; if (tabName === 'tags') { return hasAnyPermission([ @@ -166,14 +167,14 @@ export function usePermissions() { if (!requiredPermissions || !Array.isArray(requiredPermissions)) return false; if (requiredPermissions.length === 0) return true; return hasAnyPermission(requiredPermissions as unknown as string[]); - }; + }, [user, hasAnyPermission]); - const getAccessibleSettingsTabs = (): string[] => { + const getAccessibleSettingsTabs = useCallback((): string[] => { if (!user) return ['account']; - + const allTabs = Object.keys(SETTINGS_PERMISSIONS) as (keyof typeof SETTINGS_PERMISSIONS)[]; return allTabs.filter(tab => canAccessSettingsTab(tab)); - }; + }, [user, canAccessSettingsTab]); const { data: rolesData } = useQuery({ queryKey: ['/v1/panel/roles'], @@ -188,7 +189,7 @@ export function usePermissions() { const roleHierarchy = useMemo(() => { return rolesData?.roles ? buildRoleHierarchy(rolesData.roles) : new Map(); - }, [rolesData]); + }, [rolesData?.roles]); const canModifyUserRole = (targetUserRole: string, newRole?: string, targetUserId?: string): boolean => { if (!user) return false; diff --git a/client/src/hooks/use-player-lookup.tsx b/client/src/hooks/use-player-lookup.tsx index bb2c17b..99a3cf5 100644 --- a/client/src/hooks/use-player-lookup.tsx +++ b/client/src/hooks/use-player-lookup.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { apiFetch } from '@/lib/api'; @@ -50,9 +51,13 @@ export function usePlayerLookup(identifier: string) { if (!res.ok) { throw new Error('Player not found'); } - - const players = await res.json(); - if (!players || players.length === 0) { + + // The backend returns proto-JSON PlayerSearchResultsResponse, i.e. the wrapper object + // { items: [...] }, NOT a bare array. Unwrap `.items` (default to []) and guard so a + // plain object never reaches players.find(...) (which would throw TypeError). + const body = await res.json(); + const players: PlayerLookupResultItem[] = Array.isArray(body?.items) ? body.items : []; + if (players.length === 0) { throw new Error('Player not found'); } @@ -92,13 +97,23 @@ interface PunishmentLookupResult { }; } -export function usePunishmentLookup(punishmentId: string) { +export function usePunishmentLookup(punishmentId: string, debounceMs: number = 300) { + const [debouncedId, setDebouncedId] = useState(punishmentId); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedId(punishmentId); + }, debounceMs); + + return () => clearTimeout(timer); + }, [punishmentId, debounceMs]); + return useQuery({ - queryKey: ['punishment-lookup', punishmentId], + queryKey: ['punishment-lookup', debouncedId], queryFn: async (): Promise => { - if (!punishmentId) throw new Error('No punishment ID provided'); + if (!debouncedId) throw new Error('No punishment ID provided'); - const res = await apiFetch(`/v1/panel/players/punishments/${punishmentId}`); + const res = await apiFetch(`/v1/panel/players/punishments/${debouncedId}`); if (!res.ok) { if (res.status === 404) { throw new Error('Punishment not found'); @@ -121,7 +136,7 @@ export function usePunishmentLookup(punishmentId: string) { }, }; }, - enabled: !!punishmentId && punishmentId.length > 0, + enabled: !!debouncedId && debouncedId.length > 0, staleTime: 1000 * 60 * 5, retry: false }); diff --git a/client/src/hooks/use-provisioning-status.tsx b/client/src/hooks/use-provisioning-status.tsx index 64fb3e5..138cf52 100644 --- a/client/src/hooks/use-provisioning-status.tsx +++ b/client/src/hooks/use-provisioning-status.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useLocation } from 'wouter'; import { apiFetch } from '@/lib/api'; import { normalizeProvisioningStatus } from '@/lib/backend-enums'; +import { useAuth } from '@/hooks/use-auth'; interface ProvisioningStatusResponse { status?: string | null; @@ -12,8 +13,13 @@ interface ProvisioningStatusResponse { export function useProvisioningStatusCheck() { const [location, setLocation] = useLocation(); + const { user, isLoading } = useAuth(); useEffect(() => { + if (isLoading || !user) { + return; + } + const exemptPaths = [ '/provisioning-in-progress', '/auth', @@ -45,7 +51,10 @@ export function useProvisioningStatusCheck() { const provisioningStatus = normalizeProvisioningStatus(data.status ?? data.provisioningStatus); const emailVerified = data.emailVerified === true; - if (process.env.ENVIRONMENT !== 'development') { + // Vite statically replaces import.meta.env.DEV at build time; the old + // process.env.ENVIRONMENT read threw ReferenceError in the browser (no process shim), + // which the catch below swallowed -> the gate silently never enforced. + if (!import.meta.env.DEV) { if (!emailVerified) { setLocation('/verify-email?status=check&reason=email_not_verified'); } else if (provisioningStatus !== 'COMPLETED') { @@ -60,5 +69,5 @@ export function useProvisioningStatusCheck() { }; checkProvisioningStatus(); - }, [setLocation, location]); + }, [user, isLoading, setLocation, location]); } diff --git a/client/src/hooks/use-public-settings.tsx b/client/src/hooks/use-public-settings.tsx index 10913d9..10d6bb0 100644 --- a/client/src/hooks/use-public-settings.tsx +++ b/client/src/hooks/use-public-settings.tsx @@ -15,20 +15,14 @@ export function usePublicSettings() { return useQuery({ queryKey: ['/v1/public/settings'], queryFn: async () => { - try { - const res = await apiFetch('/v1/public/settings'); + // A non-ok response is a definitive answer from the server, so fall back to + // serverExists:false. A thrown transport/network error, by contrast, is transient: + // re-throw it so react-query's `retry` actually fires (it never could when the + // catch swallowed everything into a resolved success), giving a retry buffer before + // refetchOnWindowFocus can flip the UI to serverExists:false on a single blip. + const res = await apiFetch('/v1/public/settings'); - if (!res.ok) { - return { - serverExists: false, - serverDisplayName: null, - panelIconUrl: null, - homepageIconUrl: null, - }; - } - - return await res.json() as PublicSettingsData; - } catch { + if (!res.ok) { return { serverExists: false, serverDisplayName: null, @@ -36,11 +30,12 @@ export function usePublicSettings() { homepageIconUrl: null, }; } + + return await res.json() as PublicSettingsData; }, staleTime: 1000 * 30, gcTime: 1000 * 60 * 10, refetchOnWindowFocus: true, - refetchInterval: 1000 * 60, retry: 1, }); } diff --git a/client/src/hooks/use-realtime.tsx b/client/src/hooks/use-realtime.tsx new file mode 100644 index 0000000..56b0cc9 --- /dev/null +++ b/client/src/hooks/use-realtime.tsx @@ -0,0 +1,540 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { QueryClient, useQueryClient } from '@tanstack/react-query'; +import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; +import { + AckKind, + AckSchema, + ClientHelloSchema, + ClientKind, + ErrorCode, + HeartbeatSchema, + PanelResource, + RealtimeEnvelope, + RealtimeEnvelopeSchema, + ReconnectAction, + ReconnectReason, + Topic, +} from '@modl-gg/proto/modl/v1/realtime_pb.ts'; +import { useAuth } from '@/hooks/use-auth'; +import { getCurrentDomain } from '@/lib/api'; + +const REALTIME_PROTOCOL_VERSION = 1; +const HEARTBEAT_INTERVAL_MS = 30_000; +const INITIAL_RECONNECT_DELAY_MS = 1_000; +const MAX_RECONNECT_DELAY_MS = 30_000; +const MAX_ADVISED_RECONNECT_DELAY_MS = 60_000; +const RECENT_EVENT_ID_LIMIT = 256; +const REALTIME_PATH = '/api/v1/realtime/ws'; + +const PANEL_TOPICS = [ + Topic.PANEL_TICKETS, + Topic.PANEL_ASSIGNED_TICKETS, + Topic.PANEL_MIGRATIONS, + Topic.PANEL_PLAYERS, + Topic.PANEL_PUNISHMENTS, + Topic.PANEL_STAFF, + Topic.PANEL_ROLES, + Topic.PANEL_SETTINGS, + Topic.PANEL_PUNISHMENT_TYPES, + Topic.PANEL_KNOWLEDGEBASE, + Topic.PANEL_AUDIT, + Topic.PANEL_HOMEPAGE, + Topic.PANEL_APPEALS, + Topic.PANEL_DASHBOARD, + Topic.PANEL_NOTIFICATIONS, +] as const; +const PANEL_TOPIC_SET = new Set(PANEL_TOPICS); + +type InvalidateQueries = Pick; +type InvalidationTarget = readonly unknown[]; + +const STAFF_TARGETS: InvalidationTarget[] = [ + ['/v1/panel/staff'], + ['/v1/panel/roles'], + ['/v1/panel/roles/permissions'], + ['userPermissions'], +]; + +const ALL_SETTINGS_TARGETS: InvalidationTarget[] = [ + ['/v1/settings'], + ['/v1/panel/settings/punishment-types'], + ['/v1/panel/settings/ticket-forms'], + ['/v1/panel/settings/quick-responses'], + ['/v1/panel/settings/status-thresholds'], + ['/v1/panel/settings/ticket-labels'], + ['/v1/panel/settings/replay-retention'], +]; + +const DASHBOARD_TARGETS: InvalidationTarget[] = [ + ['/v1/panel/dashboard/metrics'], + ['/v1/panel/dashboard/recent-tickets'], + ['/v1/panel/dashboard/recent-punishments'], + ['/v1/panel/dashboard/alerts'], +]; + +const AUDIT_TARGETS: InvalidationTarget[] = [ + ['analytics-overview'], + ['staff-performance'], + ['punishment-analytics'], + ['player-activity'], + ['ticket-analytics'], + ['punishments-list'], + ['staff-details'], +]; + +const NOTIFICATION_TARGETS: InvalidationTarget[] = [ + ['/v1/panel/ticket-subscriptions/updates'], + ['/v1/panel/ticket-subscriptions/assigned-updates'], +]; + +export function isPanelRealtimeEnabled(): boolean { + return import.meta.env.VITE_REALTIME_ENABLED !== 'false'; +} + +export function createRealtimeWebSocketUrl(location: Location): string { + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${location.host}${REALTIME_PATH}`; +} + +export function getReconnectDelayMs(attempt: number, random: () => number = Math.random): number { + const cappedAttempt = Math.min(attempt, 5); + const baseDelay = Math.min(MAX_RECONNECT_DELAY_MS, INITIAL_RECONNECT_DELAY_MS * 2 ** cappedAttempt); + return baseDelay + Math.floor(random() * Math.min(baseDelay, 1_000)); +} + +export function getAdvisedReconnectDelayMs(retryAfterMs: number, random: () => number = Math.random): number { + const cappedDelay = Math.max(0, Math.min(retryAfterMs, MAX_ADVISED_RECONNECT_DELAY_MS)); + return cappedDelay + Math.floor(random() * Math.min(Math.max(cappedDelay, 1), 1_000)); +} + +function getTopicForPanelResource(resource: PanelResource): Topic | null { + switch (resource) { + case PanelResource.PLAYERS: + return Topic.PANEL_PLAYERS; + case PanelResource.PUNISHMENTS: + return Topic.PANEL_PUNISHMENTS; + case PanelResource.STAFF: + return Topic.PANEL_STAFF; + case PanelResource.ROLES: + return Topic.PANEL_ROLES; + case PanelResource.SETTINGS: + return Topic.PANEL_SETTINGS; + case PanelResource.PUNISHMENT_TYPES: + return Topic.PANEL_PUNISHMENT_TYPES; + case PanelResource.KNOWLEDGEBASE: + return Topic.PANEL_KNOWLEDGEBASE; + case PanelResource.AUDIT: + return Topic.PANEL_AUDIT; + case PanelResource.HOMEPAGE: + return Topic.PANEL_HOMEPAGE; + case PanelResource.APPEALS: + return Topic.PANEL_APPEALS; + case PanelResource.TICKETS: + return Topic.PANEL_TICKETS; + case PanelResource.DASHBOARD: + return Topic.PANEL_DASHBOARD; + case PanelResource.NOTIFICATIONS: + return Topic.PANEL_NOTIFICATIONS; + default: + return null; + } +} + +export function getRequiredTopicForRealtimePayload(envelope: RealtimeEnvelope): Topic | null { + switch (envelope.payload.case) { + case 'ticketChanged': + return Topic.PANEL_TICKETS; + case 'assignedTicketSubscriptionChanged': + return Topic.PANEL_ASSIGNED_TICKETS; + case 'migrationStatusChanged': + return Topic.PANEL_MIGRATIONS; + case 'panelInvalidated': + return getTopicForPanelResource(envelope.payload.value.resource); + default: + return null; + } +} + +function getPanelResourceInvalidationTargets(resource: PanelResource, resourceId?: string): InvalidationTarget[] { + const id = resourceId?.trim(); + switch (resource) { + case PanelResource.PLAYERS: + return id + ? [ + ['/v1/panel/players', id], + ['/v1/panel/players/linked', id], + ['/v1/panel/players', id, 'replays'], + ] + : [['/v1/panel/players']]; + case PanelResource.PUNISHMENTS: + return id + ? [ + ['/v1/panel/players', id], + ['/v1/panel/players/punishments', id, 'linked-bans'], + ] + : [['/v1/panel/players']]; + case PanelResource.STAFF: + case PanelResource.ROLES: + return STAFF_TARGETS; + case PanelResource.SETTINGS: + case PanelResource.PUNISHMENT_TYPES: + return ALL_SETTINGS_TARGETS; + case PanelResource.KNOWLEDGEBASE: + return [['knowledgebaseCategories'], ['homepageCards']]; + case PanelResource.HOMEPAGE: + return [['homepageCards']]; + case PanelResource.AUDIT: + return AUDIT_TARGETS; + case PanelResource.APPEALS: + return [ + ['/v1/panel/appeals'], + ...(id ? [['/v1/panel/appeals/punishment', id] as const] : []), + ]; + case PanelResource.TICKETS: + return [ + ['/v1/panel/tickets'], + ['/v1/panel/tickets/counts'], + ...(id ? [['/v1/panel/tickets', id] as const] : []), + ]; + case PanelResource.DASHBOARD: + return DASHBOARD_TARGETS; + case PanelResource.NOTIFICATIONS: + return NOTIFICATION_TARGETS; + default: + return []; + } +} + +export function getRealtimeInvalidationTargets(envelope: RealtimeEnvelope): InvalidationTarget[] { + switch (envelope.payload.case) { + case 'ticketChanged': { + const ticketId = envelope.payload.value.ticketId?.trim(); + return [ + ['/v1/panel/tickets'], + ['/v1/panel/tickets/counts'], + ...(ticketId ? [['/v1/panel/tickets', ticketId] as const] : []), + ]; + } + case 'assignedTicketSubscriptionChanged': + return [ + ['/v1/panel/ticket-subscriptions/updates'], + ['/v1/panel/ticket-subscriptions/assigned-updates'], + ]; + case 'migrationStatusChanged': + return [['/v1/panel/migration/status']]; + case 'panelInvalidated': + return getPanelResourceInvalidationTargets( + envelope.payload.value.resource, + envelope.payload.value.resourceId, + ); + default: + return []; + } +} + +function getTopicInvalidationTargets(topic: Topic): InvalidationTarget[] { + switch (topic) { + case Topic.PANEL_TICKETS: + return [ + ['/v1/panel/tickets'], + ['/v1/panel/tickets/counts'], + ]; + case Topic.PANEL_ASSIGNED_TICKETS: + return [ + ['/v1/panel/ticket-subscriptions/updates'], + ['/v1/panel/ticket-subscriptions/assigned-updates'], + ]; + case Topic.PANEL_MIGRATIONS: + return [['/v1/panel/migration/status']]; + case Topic.PANEL_PLAYERS: + case Topic.PANEL_PUNISHMENTS: + return [['/v1/panel/players']]; + case Topic.PANEL_STAFF: + case Topic.PANEL_ROLES: + return STAFF_TARGETS; + case Topic.PANEL_SETTINGS: + case Topic.PANEL_PUNISHMENT_TYPES: + return ALL_SETTINGS_TARGETS; + case Topic.PANEL_KNOWLEDGEBASE: + return [['knowledgebaseCategories'], ['homepageCards']]; + case Topic.PANEL_HOMEPAGE: + return [['homepageCards']]; + case Topic.PANEL_AUDIT: + return AUDIT_TARGETS; + case Topic.PANEL_APPEALS: + return [['/v1/panel/appeals']]; + case Topic.PANEL_DASHBOARD: + return DASHBOARD_TARGETS; + case Topic.PANEL_NOTIFICATIONS: + return NOTIFICATION_TARGETS; + default: + return []; + } +} + +function getSubscribedRealtimeInvalidationTargets(topics: readonly Topic[]): InvalidationTarget[] { + const seen = new Set(); + const targets: InvalidationTarget[] = []; + + for (const topic of topics) { + if (!PANEL_TOPIC_SET.has(topic)) continue; + + for (const target of getTopicInvalidationTargets(topic)) { + const key = JSON.stringify(target); + if (seen.has(key)) continue; + seen.add(key); + targets.push(target); + } + } + + return targets; +} + +export function invalidateRealtimeTargets(queryClient: InvalidateQueries, targets: InvalidationTarget[]): void { + for (const queryKey of targets) { + void queryClient.invalidateQueries({ queryKey }); + } +} + +export function invalidateSubscribedRealtimeTargets(queryClient: InvalidateQueries, topics: readonly Topic[]): void { + invalidateRealtimeTargets(queryClient, getSubscribedRealtimeInvalidationTargets(topics)); +} + +function buildEnvelope(payload: RealtimeEnvelope['payload']): RealtimeEnvelope { + return create(RealtimeEnvelopeSchema, { + protocolVersion: REALTIME_PROTOCOL_VERSION, + payload, + }); +} + +function buildClientHello(serverDomain: string): RealtimeEnvelope { + return buildEnvelope({ + case: 'clientHello', + value: create(ClientHelloSchema, { + clientKind: ClientKind.PANEL, + protocolVersion: REALTIME_PROTOCOL_VERSION, + serverName: serverDomain, + supportedTopics: [...PANEL_TOPICS], + }), + }); +} + +function buildHeartbeat(sequence: bigint): RealtimeEnvelope { + return buildEnvelope({ + case: 'heartbeat', + value: create(HeartbeatSchema, { sequence }), + }); +} + +function buildTransportAck(eventId: string): RealtimeEnvelope { + return buildEnvelope({ + case: 'ack', + value: create(AckSchema, { + kind: AckKind.TRANSPORT, + eventId, + }), + }); +} + +function sendEnvelope(socket: WebSocket | null, envelope: RealtimeEnvelope): void { + if (!socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(toBinary(RealtimeEnvelopeSchema, envelope)); +} + +function shouldStopForError(envelope: RealtimeEnvelope): boolean { + if (envelope.payload.case !== 'error') return false; + return envelope.payload.value.code === ErrorCode.UNAUTHORIZED + || envelope.payload.value.code === ErrorCode.FORBIDDEN + || envelope.payload.value.code === ErrorCode.UNSUPPORTED_PROTOCOL; +} + +function shouldStopForReconnectAdvice(envelope: RealtimeEnvelope): boolean { + if (envelope.payload.case !== 'reconnectAdvice') return false; + const advice = envelope.payload.value; + return advice.action === ReconnectAction.STOP + || advice.action === ReconnectAction.REAUTHENTICATE + || advice.reason === ReconnectReason.AUTH_EXPIRED + || advice.reason === ReconnectReason.PROTOCOL_UPGRADE; +} + +function markEventId(eventIds: string[], eventId: string): boolean { + if (!eventId) return true; + if (eventIds.includes(eventId)) return false; + eventIds.push(eventId); + if (eventIds.length > RECENT_EVENT_ID_LIMIT) { + eventIds.splice(0, eventIds.length - RECENT_EVENT_ID_LIMIT); + } + return true; +} + +export function useRealtimeInvalidation(): void { + const queryClient = useQueryClient(); + const { user, isLoading } = useAuth(); + const domain = getCurrentDomain(); + const authKey = useMemo(() => user ? `${domain}:${user.id}` : null, [domain, user]); + const stoppedAuthKeyRef = useRef(null); + + useEffect(() => { + if (!authKey) { + stoppedAuthKeyRef.current = null; + } + }, [authKey]); + + useEffect(() => { + if (!isPanelRealtimeEnabled() || isLoading || !authKey || typeof window === 'undefined') return; + if (stoppedAuthKeyRef.current === authKey) return; + + let active = true; + let socket: WebSocket | null = null; + let reconnectTimer: number | undefined; + let heartbeatTimer: number | undefined; + let reconnectAttempt = 0; + let heartbeatSequence = BigInt(0); + let connectedOnce = false; + let subscribedTopics: Topic[] = []; + let reconnectDelayOverrideMs: number | undefined; + const recentEventIds: string[] = []; + + const cleanupTimers = () => { + if (reconnectTimer !== undefined) { + window.clearTimeout(reconnectTimer); + reconnectTimer = undefined; + } + if (heartbeatTimer !== undefined) { + window.clearInterval(heartbeatTimer); + heartbeatTimer = undefined; + } + }; + + const stopForAuthSession = () => { + stoppedAuthKeyRef.current = authKey; + active = false; + cleanupTimers(); + socket?.close(); + socket = null; + }; + + const scheduleReconnect = () => { + if (!active || reconnectTimer !== undefined) return; + const delayMs = reconnectDelayOverrideMs !== undefined + ? getAdvisedReconnectDelayMs(reconnectDelayOverrideMs) + : getReconnectDelayMs(reconnectAttempt++); + reconnectDelayOverrideMs = undefined; + reconnectTimer = window.setTimeout(() => { + reconnectTimer = undefined; + connect(); + }, delayMs); + }; + + const startHeartbeat = () => { + if (heartbeatTimer !== undefined) { + window.clearInterval(heartbeatTimer); + } + heartbeatTimer = window.setInterval(() => { + heartbeatSequence += BigInt(1); + sendEnvelope(socket, buildHeartbeat(heartbeatSequence)); + }, HEARTBEAT_INTERVAL_MS); + }; + + const handleEnvelope = (envelope: RealtimeEnvelope) => { + if (envelope.eventId) { + sendEnvelope(socket, buildTransportAck(envelope.eventId)); + } + + if (shouldStopForError(envelope) || shouldStopForReconnectAdvice(envelope)) { + stopForAuthSession(); + return; + } + + switch (envelope.payload.case) { + case 'serverHello': + reconnectAttempt = 0; + subscribedTopics = envelope.payload.value.acceptedTopics.filter((topic) => PANEL_TOPIC_SET.has(topic)); + if (connectedOnce) { + invalidateSubscribedRealtimeTargets(queryClient, subscribedTopics); + } + connectedOnce = true; + break; + case 'heartbeat': + break; + case 'reconnectAdvice': + if (envelope.payload.value.action === ReconnectAction.RECONNECT && envelope.payload.value.retryAfterMs !== undefined) { + reconnectDelayOverrideMs = envelope.payload.value.retryAfterMs; + } + if (envelope.payload.value.action === ReconnectAction.RESYNC) { + invalidateSubscribedRealtimeTargets(queryClient, subscribedTopics); + } + break; + case 'ticketChanged': + case 'assignedTicketSubscriptionChanged': + case 'migrationStatusChanged': + case 'panelInvalidated': { + const requiredTopic = getRequiredTopicForRealtimePayload(envelope); + if (requiredTopic !== null && subscribedTopics.includes(requiredTopic) && markEventId(recentEventIds, envelope.eventId)) { + invalidateRealtimeTargets(queryClient, getRealtimeInvalidationTargets(envelope)); + } + break; + } + default: + if (import.meta.env.DEV) { + console.warn('[realtime] unhandled envelope payload case:', envelope.payload?.case); + } + break; + } + }; + + const connect = () => { + if (!active) return; + const nextSocket = new WebSocket(createRealtimeWebSocketUrl(window.location)); + socket = nextSocket; + nextSocket.binaryType = 'arraybuffer'; + + nextSocket.onopen = () => { + sendEnvelope(nextSocket, buildClientHello(domain)); + startHeartbeat(); + }; + + nextSocket.onmessage = async (event) => { + try { + const data = event.data instanceof Blob + ? new Uint8Array(await event.data.arrayBuffer()) + : new Uint8Array(event.data); + handleEnvelope(fromBinary(RealtimeEnvelopeSchema, data)); + } catch { + nextSocket.close(1003, 'Invalid realtime frame'); + } + }; + + nextSocket.onclose = (event) => { + if (socket === nextSocket) socket = null; + if (heartbeatTimer !== undefined) { + window.clearInterval(heartbeatTimer); + heartbeatTimer = undefined; + } + if (event.code === 1008 || event.code === 1013) { + stopForAuthSession(); + return; + } + scheduleReconnect(); + }; + + nextSocket.onerror = () => { + nextSocket.close(); + }; + }; + + connect(); + + return () => { + active = false; + cleanupTimers(); + socket?.close(); + socket = null; + }; + }, [authKey, isLoading, queryClient]); +} + +export function RealtimeProvider(): null { + useRealtimeInvalidation(); + return null; +} diff --git a/client/src/hooks/use-sidebar.tsx b/client/src/hooks/use-sidebar.tsx index b6134b1..8168696 100644 --- a/client/src/hooks/use-sidebar.tsx +++ b/client/src/hooks/use-sidebar.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, ReactNode } from 'react'; +import { createContext, useContext, useMemo, useState, ReactNode } from 'react'; interface SidebarContextType { isSearchActive: boolean; @@ -10,11 +10,13 @@ const SidebarContext = createContext(undefined); export const SidebarProvider = ({ children }: { children: ReactNode }) => { const [isSearchActive, setIsSearchActive] = useState(false); + const value = useMemo( + () => ({ isSearchActive, setIsSearchActive }), + [isSearchActive] + ); + return ( - + {children} ); diff --git a/client/src/lib/backend-enums.ts b/client/src/lib/backend-enums.ts index 11d9425..63919d7 100644 --- a/client/src/lib/backend-enums.ts +++ b/client/src/lib/backend-enums.ts @@ -77,7 +77,9 @@ export function normalizeSubscriptionStatus(value: NullableString): Subscription } export function normalizeProvisioningStatus(value: NullableString): ProvisioningStatus { - const normalized = normalizeEnumKey(value); + // Backend now emits the proto enum names (PROVISIONING_STATUS_COMPLETED), so strip that + // prefix before matching while still accepting the legacy bare/lowercase forms. + const normalized = normalizeEnumKey(value).replace(/^PROVISIONING_STATUS_/, ""); return PROVISIONING_STATUS_SET.has(normalized as ProvisioningStatus) ? (normalized as ProvisioningStatus) : "PENDING"; diff --git a/client/src/lib/proto-fetch.ts b/client/src/lib/proto-fetch.ts new file mode 100644 index 0000000..22a75a5 --- /dev/null +++ b/client/src/lib/proto-fetch.ts @@ -0,0 +1,75 @@ +import { fromJson, toJson, type DescMessage, type MessageShape } from '@bufbuild/protobuf'; +import { apiFetch } from '@/lib/api'; + +type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; +type MutateMethod = 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +interface ProtoFetchInit { + method?: RequestMethod; + body?: unknown; +} + +// Backend ProtoJsonHttpMessageConverter emits canonical proto-JSON. ignoreUnknownFields +// guards against additive backend fields landing before the panel proto bump, so a new +// field never throws in production. +const READ_OPTS = { ignoreUnknownFields: true } as const; + +export class ProtoHttpError extends Error { + constructor( + readonly status: number, + readonly statusText: string, + readonly bodyText: string, + ) { + super(`${status}: ${bodyText || statusText}`); + this.name = 'ProtoHttpError'; + } +} + +async function readErrorBody(res: Response): Promise { + return res.text().catch(() => ''); +} + +/** GET (or any verb) → decode the proto-JSON body into a typed message. */ +export async function protoFetch( + schema: Desc, + path: string, + init?: ProtoFetchInit, +): Promise> { + const res = await apiFetch(path, { method: init?.method ?? 'GET', body: init?.body }); + if (!res.ok) { + throw new ProtoHttpError(res.status, res.statusText, await readErrorBody(res)); + } + return fromJson(schema, await res.json(), READ_OPTS); +} + +/** GET that resolves to null on 404 (mirrors the existing `if 404 return null` idiom). */ +export async function protoFetchOrNull( + schema: Desc, + path: string, + init?: ProtoFetchInit, +): Promise | null> { + const res = await apiFetch(path, { method: init?.method ?? 'GET', body: init?.body }); + if (res.status === 404) return null; + if (!res.ok) { + throw new ProtoHttpError(res.status, res.statusText, await readErrorBody(res)); + } + return fromJson(schema, await res.json(), READ_OPTS); +} + +/** Encode a typed request message to proto-JSON, send it, and decode the typed response. */ +export async function protoSend( + method: MutateMethod, + path: string, + reqSchema: ReqDesc, + req: MessageShape, + resSchema: ResDesc, +): Promise> { + // toJson returns a plain object; apiFetch performs the single JSON.stringify. Pass the + // object, not a pre-stringified string, so api.ts keeps its one stringify path. + const body = toJson(reqSchema, req); + const res = await apiFetch(path, { method, body }); + if (!res.ok) { + throw new ProtoHttpError(res.status, res.statusText, await readErrorBody(res)); + } + return fromJson(resSchema, await res.json(), READ_OPTS); +} diff --git a/client/src/lib/proto-ui.ts b/client/src/lib/proto-ui.ts new file mode 100644 index 0000000..6b9f198 --- /dev/null +++ b/client/src/lib/proto-ui.ts @@ -0,0 +1,9 @@ +import { timestampDate, type Timestamp } from '@bufbuild/protobuf/wkt'; + +export const tsToDate = (t?: Timestamp): Date | null => (t ? timestampDate(t) : null); + +export const tsToMillis = (t?: Timestamp): number | null => (t ? timestampDate(t).getTime() : null); + +// bigint → number ONLY for values known to be < 2^53 (counts, durations, ids that fit). +// Never blindly Number() a bigint that could exceed Number.MAX_SAFE_INTEGER. +export const toNum = (v: bigint): number => Number(v); diff --git a/client/src/lib/punishment-mapping.ts b/client/src/lib/punishment-mapping.ts new file mode 100644 index 0000000..7e497d1 --- /dev/null +++ b/client/src/lib/punishment-mapping.ts @@ -0,0 +1,37 @@ +import { toNum } from '@/lib/proto-ui'; +import type { + PunishmentResponse, + PunishmentModification, + PunishmentNote, + PunishmentEvidence, +} from '@modl-gg/proto/modl/v1/punishment_pb.ts'; + +const epochToIso = (millis: bigint | undefined): string | null => + millis === undefined ? null : new Date(toNum(millis)).toISOString(); + +const mapModification = (mod: PunishmentModification) => ({ + ...mod, + date: epochToIso(mod.date), + effectiveDuration: mod.effectiveDuration === undefined ? undefined : toNum(mod.effectiveDuration), +}); + +const mapNote = (note: PunishmentNote) => ({ + ...note, + date: epochToIso(note.date), +}); + +const mapEvidence = (evidence: PunishmentEvidence) => ({ + ...evidence, + uploadedAt: epochToIso(evidence.uploadedAt), + fileSize: evidence.fileSize === undefined ? undefined : toNum(evidence.fileSize), +}); + +export const mapPunishment = (punishment: PunishmentResponse) => ({ + ...punishment, + issued: epochToIso(punishment.issued), + expires: epochToIso(punishment.expires), + started: epochToIso(punishment.started), + modifications: punishment.modifications.map(mapModification), + notes: punishment.notes.map(mapNote), + evidence: punishment.evidence.map(mapEvidence), +}); diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts index 3a84d6b..819f1db 100644 --- a/client/src/lib/queryClient.ts +++ b/client/src/lib/queryClient.ts @@ -64,7 +64,8 @@ export const queryClient = new QueryClient({ queryFn: getQueryFn({ on401: "throw" }), refetchInterval: false, refetchOnWindowFocus: false, - staleTime: Infinity, + refetchOnReconnect: true, + staleTime: 30_000, retry: (failureCount, error) => { if (failureCount >= 1) return false; if (error instanceof Error) { diff --git a/client/src/lib/replay-api.ts b/client/src/lib/replay-api.ts index 8dc7e30..9aa0561 100644 --- a/client/src/lib/replay-api.ts +++ b/client/src/lib/replay-api.ts @@ -3,14 +3,18 @@ import { apiFetch } from './api'; export interface ReplayMetadata { replayId: string; mcVersion: string; - fileSize: number; - timestamp: number; + // Backend serializes these proto int64 fields as canonical proto-JSON, which encodes + // int64 as JSON strings (not numbers). Type them as string and coerce with Number()/BigInt + // at any use site that needs arithmetic or new Date(...). + fileSize: string; + timestamp: string; replayUrl: string; status: string; + labeled: boolean; } export async function submitReplayLabels(replayId: string, labels: unknown[]): Promise { - const response = await apiFetch(`/v1/public/replays/${encodeURIComponent(replayId)}/label`, { + const response = await apiFetch(`/v1/panel/replays/${encodeURIComponent(replayId)}/label`, { method: 'POST', body: { players: labels }, }); diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts index bd0c391..d0fddc6 100644 --- a/client/src/lib/utils.ts +++ b/client/src/lib/utils.ts @@ -4,3 +4,29 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * Safely open an external URL in a new tab. Accepts only http(s) absolute URLs or + * same-origin root-relative paths ('/...'); rejects javascript:/data:/blob: and + * protocol-relative '//host'. Opens with 'noopener,noreferrer' and defensively nulls + * opener to prevent reverse-tabnabbing and Referer leakage. Returns false if blocked. + */ +export function openExternalUrl(url: string | undefined | null): boolean { + if (!url || typeof url !== 'string') return false; + const trimmed = url.trim(); + // Root-relative same-origin path, but reject protocol-relative '//host'. + const isRelative = trimmed.startsWith('/') && !trimmed.startsWith('//'); + let isHttp = false; + if (!isRelative) { + try { + const parsed = new URL(trimmed, window.location.origin); + isHttp = parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } + } + if (!isRelative && !isHttp) return false; + const win = window.open(trimmed, '_blank', 'noopener,noreferrer'); + if (win) win.opener = null; + return true; +} diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 5daf035..7dd7cbb 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -751,12 +751,17 @@ "storageOverage": "Speicherüberschreitung", "costThisMonth": "Kosten diesen Monat", "storageRate": "$0,08 pro GB pro Monat", - "systemStatus": "Systemstatus", - "totalFiles": "Dateien gesamt", - "selected": "Ausgewählt", - "filtered": "Gefiltert", - "canUpload": "Kann hochladen", - "aiAvailable": "KI verfügbar", + "replayRetention": "Replay-Aufbewahrung", + "replayRetentionEnabled": "Automatische Replay-Bereinigung aktivieren", + "replayRetentionEnabledDesc": "Gespeicherte Replays nach der konfigurierten Aufbewahrungszeit automatisch löschen.", + "replayRetentionDays": "Aufbewahrungszeitraum (Tage)", + "replayRetentionDaysDesc": "Gespeicherte Replays, die älter als diese Anzahl von Tagen sind, können bereinigt werden.", + "replayRetentionDisabledDesc": "Automatische Replay-Bereinigung ist deaktiviert. Gespeicherte Replays bleiben erhalten, bis sie manuell gelöscht werden.", + "replayRetentionActive": "Replays werden nach {{days}} Tagen gelöscht", + "replayRetentionOff": "Automatische Bereinigung deaktiviert", + "replayRetentionSaved": "Replay-Aufbewahrungseinstellungen gespeichert.", + "replayRetentionSaveFailed": "Replay-Aufbewahrungseinstellungen konnten nicht gespeichert werden.", + "replayRetentionLoadFailed": "Replay-Aufbewahrungseinstellungen konnten nicht geladen werden.", "fileManagement": "Dateiverwaltung", "searchFiles": "Suchen", "searchPlaceholder": "Dateien suchen...", @@ -960,6 +965,16 @@ "refreshed": "Dashboard aktualisiert", "refreshedDesc": "Alle Dashboard-Daten wurden aktualisiert.", "refreshError": "Dashboard konnte nicht aktualisiert werden. Bitte versuche es erneut.", + "alerts": { + "expires": "Läuft ab am {{date}}", + "unknownSeverity": "Hinweis ({{severity}})", + "unknownSeverityValue": "Unbekannt", + "levels": { + "BASIC": "Hinweis", + "WARNING": "Warnung", + "CRITICAL": "Kritischer Hinweis" + } + }, "recentTickets": { "title": "Kürzlich geöffnete Tickets", "noMessage": "Keine Nachricht verfügbar", @@ -1011,6 +1026,7 @@ "label": "Label", "assignee": "Zugewiesen an", "unassigned": "Nicht zugewiesen", + "hidden": "Ausgeblendet", "newest": "Neueste", "oldest": "Älteste", "recentlyUpdated": "Kürzlich aktualisiert", @@ -1376,6 +1392,7 @@ "tabConnectedShort": "Verknüpft", "tabNotes": "Notizen", "tabTickets": "Tickets", + "tabReplays": "Replays", "tabNames": "Namen", "tabPunish": "Bestrafen", "tabs": { @@ -1383,6 +1400,7 @@ "connected": "Verbunden", "notes": "Notizen", "tickets": "Tickets", + "replays": "Replays", "names": "Namen", "punish": "Bestrafen" }, @@ -1446,6 +1464,16 @@ "playerTickets": "Spieler-Tickets", "loadingTickets": "Tickets werden geladen...", "noTickets": "Keine Tickets für diesen Spieler gefunden.", + "playerReplays": "Spieler-Replays", + "loadingReplays": "Replays werden geladen...", + "noReplays": "Keine Replays für diesen Spieler gefunden.", + "replaysLoadFailed": "Replays für diesen Spieler konnten nicht geladen werden.", + "openReplay": "Replay öffnen", + "replayCreated": "Erstellt", + "replayMcVersion": "Minecraft", + "replaySize": "Größe", + "replayId": "Replay-ID", + "unknownPlayer": "Unbekannter Spieler", "previousNames": "Frühere Namen", "missingInfo": "Fehlende Informationen", "selectPunishmentType": "Bitte wähle einen Straftyp aus, bevor du ihn anwendest.", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 44e0664..dbd2970 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -585,6 +585,7 @@ "addLabel": "Add Label", "quickColors": "Quick colors:", "deleteLabelTitle": "Delete Label", + "deleteLabelConfirm": "Are you sure you want to delete the label \"{{name}}\"? This action cannot be undone.", "quickResponses": "Quick Responses", "quickResponsesDesc": "Create pre-written responses that staff can quickly apply to tickets.", "noCategoriesYet": "No categories yet. Create one to get started.", @@ -758,12 +759,17 @@ "storageOverage": "Storage Overage", "costThisMonth": "Cost this month", "storageRate": "$0.08 per GB per month", - "systemStatus": "System Status", - "totalFiles": "Total files", - "selected": "Selected", - "filtered": "Filtered", - "canUpload": "Can upload", - "aiAvailable": "AI available", + "replayRetention": "Replay Retention", + "replayRetentionEnabled": "Enable automatic replay cleanup", + "replayRetentionEnabledDesc": "Automatically delete stored replays after the configured retention period.", + "replayRetentionDays": "Retention period (days)", + "replayRetentionDaysDesc": "Stored replays older than this many days are eligible for cleanup.", + "replayRetentionDisabledDesc": "Automatic replay cleanup is disabled. Stored replays will be retained until manually deleted.", + "replayRetentionActive": "Deleting replays after {{days}} days", + "replayRetentionOff": "Automatic cleanup disabled", + "replayRetentionSaved": "Replay retention settings saved.", + "replayRetentionSaveFailed": "Failed to save replay retention settings.", + "replayRetentionLoadFailed": "Failed to load replay retention settings.", "fileManagement": "File Management", "searchFiles": "Search", "searchPlaceholder": "Search files...", @@ -967,6 +973,16 @@ "refreshed": "Dashboard Refreshed", "refreshedDesc": "All dashboard data has been updated.", "refreshError": "Failed to refresh dashboard. Please try again.", + "alerts": { + "expires": "Expires {{date}}", + "unknownSeverity": "Alert ({{severity}})", + "unknownSeverityValue": "Unknown", + "levels": { + "BASIC": "Notice", + "WARNING": "Warning", + "CRITICAL": "Critical Alert" + } + }, "recentTickets": { "title": "Recently Opened Tickets", "noMessage": "No message available", @@ -1018,6 +1034,7 @@ "label": "Label", "assignee": "Assignee", "unassigned": "Unassigned", + "hidden": "Hidden", "newest": "Newest", "oldest": "Oldest", "recentlyUpdated": "Recently Updated", @@ -1383,6 +1400,7 @@ "tabConnectedShort": "Linked", "tabNotes": "Notes", "tabTickets": "Tickets", + "tabReplays": "Replays", "tabNames": "Names", "tabPunish": "Punish", "tabs": { @@ -1390,6 +1408,7 @@ "connected": "Connected", "notes": "Notes", "tickets": "Tickets", + "replays": "Replays", "names": "Names", "punish": "Punish" }, @@ -1453,6 +1472,16 @@ "playerTickets": "Player Tickets", "loadingTickets": "Loading tickets...", "noTickets": "No tickets found for this player.", + "playerReplays": "Player Replays", + "loadingReplays": "Loading replays...", + "noReplays": "No replays found for this player.", + "replaysLoadFailed": "Failed to load replays for this player.", + "openReplay": "Open Replay", + "replayCreated": "Created", + "replayMcVersion": "Minecraft", + "replaySize": "Size", + "replayId": "Replay ID", + "unknownPlayer": "Unknown player", "previousNames": "Previous Names", "missingInfo": "Missing information", "selectPunishmentType": "Please select a punishment type before applying.", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 80a9d81..0187785 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -751,12 +751,17 @@ "storageOverage": "Exceso de almacenamiento", "costThisMonth": "Costo este mes", "storageRate": "$0,08 por GB por mes", - "systemStatus": "Estado del sistema", - "totalFiles": "Total de archivos", - "selected": "Seleccionados", - "filtered": "Filtrados", - "canUpload": "Puede subir", - "aiAvailable": "IA disponible", + "replayRetention": "Retención de repeticiones", + "replayRetentionEnabled": "Habilitar limpieza automática de repeticiones", + "replayRetentionEnabledDesc": "Eliminar automáticamente las repeticiones almacenadas después del periodo de retención configurado.", + "replayRetentionDays": "Periodo de retención (días)", + "replayRetentionDaysDesc": "Las repeticiones almacenadas con más de esta cantidad de días podrán limpiarse.", + "replayRetentionDisabledDesc": "La limpieza automática de repeticiones está deshabilitada. Las repeticiones almacenadas se conservarán hasta que se eliminen manualmente.", + "replayRetentionActive": "Eliminando repeticiones después de {{days}} días", + "replayRetentionOff": "Limpieza automática deshabilitada", + "replayRetentionSaved": "Configuración de retención de repeticiones guardada.", + "replayRetentionSaveFailed": "No se pudo guardar la configuración de retención de repeticiones.", + "replayRetentionLoadFailed": "No se pudo cargar la configuración de retención de repeticiones.", "fileManagement": "Gestión de archivos", "searchFiles": "Buscar", "searchPlaceholder": "Buscar archivos...", @@ -960,6 +965,16 @@ "refreshed": "Panel actualizado", "refreshedDesc": "Todos los datos del panel han sido actualizados.", "refreshError": "No se pudo actualizar el panel. Por favor inténtalo de nuevo.", + "alerts": { + "expires": "Caduca el {{date}}", + "unknownSeverity": "Alerta ({{severity}})", + "unknownSeverityValue": "Desconocida", + "levels": { + "BASIC": "Aviso", + "WARNING": "Advertencia", + "CRITICAL": "Alerta crítica" + } + }, "recentTickets": { "title": "Tickets abiertos recientemente", "noMessage": "No hay mensaje disponible", @@ -1011,6 +1026,7 @@ "label": "Etiqueta", "assignee": "Asignado a", "unassigned": "Sin asignar", + "hidden": "Oculto", "newest": "Más nuevos", "oldest": "Más antiguos", "recentlyUpdated": "Actualizado recientemente", @@ -1376,6 +1392,7 @@ "tabConnectedShort": "Vinculado", "tabNotes": "Notas", "tabTickets": "Tickets", + "tabReplays": "Repeticiones", "tabNames": "Nombres", "tabPunish": "Sancionar", "tabs": { @@ -1383,6 +1400,7 @@ "connected": "Conectado", "notes": "Notas", "tickets": "Tickets", + "replays": "Repeticiones", "names": "Nombres", "punish": "Sancionar" }, @@ -1446,6 +1464,16 @@ "playerTickets": "Tickets del jugador", "loadingTickets": "Cargando tickets...", "noTickets": "No se encontraron tickets para este jugador.", + "playerReplays": "Repeticiones del jugador", + "loadingReplays": "Cargando repeticiones...", + "noReplays": "No se encontraron repeticiones para este jugador.", + "replaysLoadFailed": "No se pudieron cargar las repeticiones de este jugador.", + "openReplay": "Abrir repetición", + "replayCreated": "Creada", + "replayMcVersion": "Minecraft", + "replaySize": "Tamaño", + "replayId": "ID de repetición", + "unknownPlayer": "Jugador desconocido", "previousNames": "Nombres anteriores", "missingInfo": "Información faltante", "selectPunishmentType": "Por favor selecciona un tipo de sanción antes de aplicar.", diff --git a/client/src/locales/nl.json b/client/src/locales/nl.json index d2c1a4f..26b16c3 100644 --- a/client/src/locales/nl.json +++ b/client/src/locales/nl.json @@ -751,12 +751,17 @@ "storageOverage": "Opslagoverschrijding", "costThisMonth": "Kosten deze maand", "storageRate": "$0,08 per GB per maand", - "systemStatus": "Systeemstatus", - "totalFiles": "Totaal bestanden", - "selected": "Geselecteerd", - "filtered": "Gefilterd", - "canUpload": "Kan uploaden", - "aiAvailable": "AI beschikbaar", + "replayRetention": "Replaybewaring", + "replayRetentionEnabled": "Automatische replay-opruiming inschakelen", + "replayRetentionEnabledDesc": "Verwijder opgeslagen replays automatisch na de ingestelde bewaartermijn.", + "replayRetentionDays": "Bewaartermijn (dagen)", + "replayRetentionDaysDesc": "Opgeslagen replays ouder dan dit aantal dagen komen in aanmerking voor opruiming.", + "replayRetentionDisabledDesc": "Automatische replay-opruiming is uitgeschakeld. Opgeslagen replays blijven bewaard totdat ze handmatig worden verwijderd.", + "replayRetentionActive": "Replays worden na {{days}} dagen verwijderd", + "replayRetentionOff": "Automatische opruiming uitgeschakeld", + "replayRetentionSaved": "Replaybewaarinstellingen opgeslagen.", + "replayRetentionSaveFailed": "Replaybewaarinstellingen opslaan mislukt.", + "replayRetentionLoadFailed": "Replaybewaarinstellingen laden mislukt.", "fileManagement": "Bestandsbeheer", "searchFiles": "Zoeken", "searchPlaceholder": "Bestanden zoeken...", @@ -960,6 +965,16 @@ "refreshed": "Dashboard vernieuwd", "refreshedDesc": "Alle dashboardgegevens zijn bijgewerkt.", "refreshError": "Dashboard vernieuwen mislukt. Probeer het opnieuw.", + "alerts": { + "expires": "Verloopt op {{date}}", + "unknownSeverity": "Melding ({{severity}})", + "unknownSeverityValue": "Onbekend", + "levels": { + "BASIC": "Melding", + "WARNING": "Waarschuwing", + "CRITICAL": "Kritieke melding" + } + }, "recentTickets": { "title": "Recent geopende tickets", "noMessage": "Geen bericht beschikbaar", @@ -1011,6 +1026,7 @@ "label": "Label", "assignee": "Toegewezen aan", "unassigned": "Niet toegewezen", + "hidden": "Verborgen", "newest": "Nieuwste", "oldest": "Oudste", "recentlyUpdated": "Recent bijgewerkt", @@ -1376,6 +1392,7 @@ "tabConnectedShort": "Gekoppeld", "tabNotes": "Notities", "tabTickets": "Tickets", + "tabReplays": "Replays", "tabNames": "Namen", "tabPunish": "Bestraffen", "tabs": { @@ -1383,6 +1400,7 @@ "connected": "Verbonden", "notes": "Notities", "tickets": "Tickets", + "replays": "Replays", "names": "Namen", "punish": "Bestraffen" }, @@ -1446,6 +1464,16 @@ "playerTickets": "Spelertickets", "loadingTickets": "Tickets laden...", "noTickets": "Geen tickets gevonden voor deze speler.", + "playerReplays": "Spelerreplays", + "loadingReplays": "Replays laden...", + "noReplays": "Geen replays gevonden voor deze speler.", + "replaysLoadFailed": "Replays voor deze speler laden mislukt.", + "openReplay": "Replay openen", + "replayCreated": "Gemaakt", + "replayMcVersion": "Minecraft", + "replaySize": "Grootte", + "replayId": "Replay-ID", + "unknownPlayer": "Onbekende speler", "previousNames": "Vorige namen", "missingInfo": "Ontbrekende informatie", "selectPunishmentType": "Selecteer een straftype voordat je het toepast.", diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 42cb51d..3323205 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -1,17 +1,52 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'wouter'; -import { Search, Shield, MessageCircle, UserPlus, FileText, BookOpen, ChevronDown, LogIn, Sun, Moon } from 'lucide-react'; +import { + Search, Shield, MessageCircle, UserPlus, FileText, BookOpen, ChevronDown, LogIn, Sun, Moon, + Mail, Phone, Scale, Users, User, UserCheck, UserX, Crown, Award, + Book, ScrollText, Newspaper, Library, GraduationCap, HelpCircle, Info, AlertCircle, + ExternalLink, Link as LinkIcon, ArrowRight, ChevronRight, Home, + Eye, Download, Upload, Share, + MessageSquare, Send, Inbox, Bell, Megaphone, + Radio, Headphones, Mic, Video, Calendar, + Gamepad2, Zap, Server, Globe, Wifi, Signal, + Activity, BarChart, TrendingUp, Target, Trophy, + Settings, Wrench, Cog, Sliders, Filter, + Lock, Unlock, Key, ShieldCheck, ShieldAlert, + Plus, Minus, Check, X, AlertTriangle, CheckCircle, + XCircle, Clock, Timer, Pause, Play, + CreditCard, DollarSign, Gift, Star, Heart, ThumbsUp, + Flag, Map, Compass, Navigation, Bookmark, Tag, +} from 'lucide-react'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { Input } from '@modl-gg/shared-web/components/ui/input'; import { Card, CardContent, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; import { Collapsible, CollapsibleTrigger } from '@modl-gg/shared-web/components/ui/collapsible'; import { useTheme } from 'next-themes'; import serverLogo from '@/assets/server-logo.png'; -import * as LucideIcons from 'lucide-react'; import { usePublicSettings } from '@/hooks/use-public-settings'; import { useAuth } from '@/hooks/use-auth'; import { apiFetch } from '@/lib/api'; +// Allowlist of icons usable by homepage cards; mirrors HomepageCardSettings curated list. +const ICONS: Record> = { + Shield, UserPlus, MessageCircle, Mail, Phone, Scale, + Users, User, UserCheck, UserX, Crown, Award, + BookOpen, Book, FileText, ScrollText, Newspaper, + Library, GraduationCap, HelpCircle, Info, AlertCircle, + ExternalLink, Link: LinkIcon, ArrowRight, ChevronRight, Home, + Search, Eye, Download, Upload, Share, + MessageSquare, Send, Inbox, Bell, Megaphone, + Radio, Headphones, Mic, Video, Calendar, + Gamepad2, Zap, Server, Globe, Wifi, Signal, + Activity, BarChart, TrendingUp, Target, Trophy, + Settings, Wrench, Cog, Sliders, Filter, + Lock, Unlock, Key, ShieldCheck, ShieldAlert, + Plus, Minus, Check, X, AlertTriangle, CheckCircle, + XCircle, Clock, Timer, Pause, Play, + CreditCard, DollarSign, Gift, Star, Heart, ThumbsUp, + Flag, Map, Compass, Navigation, Bookmark, Tag, +}; + // Types for knowledgebase data interface ArticleStub { id: string; @@ -71,7 +106,7 @@ const HomePage: React.FC = () => { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); - setCategories(data); + setCategories(Array.isArray(data.categories) ? data.categories : []); } catch (e: any) { console.error('Failed to load categories:', e); setCategories([]); @@ -91,7 +126,7 @@ const HomePage: React.FC = () => { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); - setHomepageCards(data); + setHomepageCards(Array.isArray(data.cards) ? data.cards : []); } catch (e: any) { console.error('Failed to load homepage cards:', e); // Fallback to default cards if no custom cards are found @@ -119,7 +154,7 @@ const HomePage: React.FC = () => { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); - setSearchResults(data); + setSearchResults(Array.isArray(data.articles) ? data.articles : []); } catch (e: any) { console.error('Search failed:', e); setSearchResults([]); @@ -137,8 +172,7 @@ const HomePage: React.FC = () => { // Function to get the icon component from Lucide const getIconComponent = (iconName: string) => { - const IconComponent = (LucideIcons as any)[iconName]; - return IconComponent || BookOpen; // Fallback to BookOpen if icon not found + return ICONS[iconName] || BookOpen; // Fallback to BookOpen if icon not found }; // Function to toggle expanded state for category dropdown cards diff --git a/client/src/pages/KnowledgebasePage.tsx b/client/src/pages/KnowledgebasePage.tsx index ba3d4a3..c67a57a 100644 --- a/client/src/pages/KnowledgebasePage.tsx +++ b/client/src/pages/KnowledgebasePage.tsx @@ -42,7 +42,7 @@ const KnowledgebasePage: React.FC = () => { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); - setCategories(data); + setCategories(Array.isArray(data.categories) ? data.categories : []); setError(null); } catch (e: any) { setError(e.message || 'Failed to load categories.'); @@ -72,7 +72,7 @@ const KnowledgebasePage: React.FC = () => { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); - setSearchResults(data); + setSearchResults(Array.isArray(data.articles) ? data.articles : []); } catch (e: any) { console.error('Search failed:', e); setSearchResults([]); // Clear results on error diff --git a/client/src/pages/RateLimitPage.tsx b/client/src/pages/RateLimitPage.tsx index 394b595..46f20e8 100644 --- a/client/src/pages/RateLimitPage.tsx +++ b/client/src/pages/RateLimitPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; -import { useRouter } from 'wouter'; +import { useLocation } from 'wouter'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { AlertCircle, Clock, RefreshCw } from 'lucide-react'; +import { Notice } from '@/components/ui/notice'; import { useTranslation } from 'react-i18next'; interface RateLimitInfo { @@ -16,7 +17,7 @@ interface RateLimitInfo { export default function RateLimitPage() { const { t } = useTranslation(); - const [, navigate] = useRouter(); + const [, navigate] = useLocation(); const [timeLeft, setTimeLeft] = useState(0); const [rateLimitInfo, setRateLimitInfo] = useState({}); @@ -76,7 +77,7 @@ export default function RateLimitPage() { sessionStorage.removeItem('rateLimitInfo'); // Try to go back to the previous page, or dashboard as fallback - const returnPath = sessionStorage.getItem('preRateLimitPath') || '/dashboard'; + const returnPath = sessionStorage.getItem('preRateLimitPath') || '/panel'; sessionStorage.removeItem('preRateLimitPath'); navigate(returnPath); }; @@ -84,35 +85,35 @@ export default function RateLimitPage() { const handleDashboard = () => { sessionStorage.removeItem('rateLimitInfo'); sessionStorage.removeItem('preRateLimitPath'); - navigate('/dashboard'); + navigate('/panel'); }; return ( -
+
-
- +
+
- + {t('pages.rateLimit.title')} - + {t('pages.rateLimit.description')} - + {/* Time remaining display */} {timeLeft > 0 && ( -
+
- - + + {t('pages.rateLimit.timeRemaining')}
-
+
{formatTime(timeLeft)}
@@ -120,23 +121,23 @@ export default function RateLimitPage() { {/* Rate limit details */} {rateLimitInfo.rateLimit && ( -
+
{t('pages.rateLimit.rateLimitLabel')} {rateLimitInfo.rateLimit}
)} {/* Custom message */} {rateLimitInfo.message && ( -
+
{rateLimitInfo.message}
)} {/* Security note */} {rateLimitInfo.securityNote && ( -
- {t('pages.rateLimit.securityInfo')} {rateLimitInfo.securityNote} -
+ + {rateLimitInfo.securityNote} + )} {/* Action buttons */} @@ -173,7 +174,7 @@ export default function RateLimitPage() {
{/* Help text */} -
+
{t('pages.rateLimit.helpText')}
diff --git a/client/src/pages/api-docs.tsx b/client/src/pages/api-docs.tsx index c003fdd..e9da3c6 100644 --- a/client/src/pages/api-docs.tsx +++ b/client/src/pages/api-docs.tsx @@ -319,11 +319,12 @@ const ApiDocs = () => { {endpoint.routes.map((route, index) => (
- {route.method} diff --git a/client/src/pages/appeals.tsx b/client/src/pages/appeals.tsx index 9a709eb..c618716 100644 --- a/client/src/pages/appeals.tsx +++ b/client/src/pages/appeals.tsx @@ -38,35 +38,35 @@ import { Separator } from '@modl-gg/shared-web/components/ui/separator'; import { Badge } from '@modl-gg/shared-web/components/ui/badge'; import { useCreateAppeal } from '@/hooks/use-data'; import { formatAppealStatusLabel, isTerminalAppealStatus, normalizeAppealStatus } from '@/lib/ticket-enums'; +import type { AppealFormField, AppealFormSettings } from '@/types/forms'; + +// proto int64 timestamp fields serialize to JSON as epoch-millis strings (e.g. "1700000000000"). +// new Date("1700000000000") yields Invalid Date, so coerce to a Number before formatting. +const formatEpochMillis = (value: string | number | undefined | null): string => { + if (value === undefined || value === null || value === '') { + return 'Unknown'; + } + const millis = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(millis)) { + // Fall back to string parsing for ISO/date strings. + return formatDate(String(value)); + } + return formatDate(new Date(millis).toISOString()); +}; -interface AppealFormField { - id: string; - type: 'text' | 'textarea' | 'dropdown' | 'multiple_choice' | 'checkbox' | 'file_upload' | 'checkboxes'; - label: string; - description?: string; - required: boolean; - options?: string[]; - order: number; - sectionId?: string; - goToSection?: string; - optionSectionMapping?: Record; -} - -interface AppealFormSection { - id: string; - title: string; - description?: string; - order: number; - showIfFieldId?: string; - showIfValue?: string; - showIfValues?: string[]; - hideByDefault?: boolean; -} - -interface AppealFormSettings { - fields: AppealFormField[]; - sections: AppealFormSection[]; -} +// Convert a proto int64 epoch-millis string (or number/ISO string) to an ISO string that +// formatDate / new Date can parse. Returns undefined when no usable timestamp is present. +const epochMillisToISO = (value: string | number | undefined | null): string | undefined => { + if (value === undefined || value === null || value === '') { + return undefined; + } + const millis = typeof value === 'number' ? value : Number(value); + if (Number.isFinite(millis)) { + return new Date(millis).toISOString(); + } + // Already an ISO/date string. + return String(value); +}; // Format date to MM/dd/yy HH:mm in browser's timezone @@ -325,10 +325,10 @@ const AppealsPage = () => { const banInfo: BanInfo = { id: punishment.id, reason: 'Punishment details are not available publicly', // Reason is no longer provided by public API - date: formatDate(punishment.issued), + date: formatEpochMillis(punishment.issued), staffMember: 'Staff', // Public API doesn't expose staff member names status: punishment.active ? 'Active' : 'Expired', - expiresIn: punishment.expires ? formatDate(punishment.expires) : 'Permanent', // Use the expires field from API + expiresIn: punishment.expires ? formatEpochMillis(punishment.expires) : 'Permanent', // Use the expires field from API type: punishment.type, playerUuid: punishment.playerUuid, // Use the actual UUID from the API response isAppealable: punishment.appealable, // Use the appealable field from public API @@ -604,13 +604,14 @@ const AppealsPage = () => { submittedOn: appealData.created || appealData.submittedDate, status: appealData.appealWorkflowStatus || appealData.status, lastUpdate: appealData.updatedAt || appealData.created, - messages: (appealData.replies || []).map((reply: any) => ({ - id: reply._id || reply.id || `msg-${Date.now()}-${Math.random()}`, - sender: reply.type === 'player' || reply.senderType === 'user' ? 'player' : - reply.type === 'staff' || reply.senderType === 'staff' ? 'staff' : 'system', + messages: (appealData.messages || []).map((reply: any) => ({ + id: reply.id || `msg-${Date.now()}-${Math.random()}`, + // proto TicketReply exposes staff-ness via the `staff` boolean, not a senderType/type string. + sender: reply.staff ? 'staff' : 'player', senderName: reply.name || reply.sender, content: reply.content, - timestamp: reply.created || reply.timestamp, + // `created` is a proto int64 -> epoch-millis string; coerce so formatDate works. + timestamp: epochMillisToISO(reply.created) || reply.timestamp, isStaffNote: reply.type === 'staff-note', attachments: reply.attachments || [] })) diff --git a/client/src/pages/audit.tsx b/client/src/pages/audit.tsx index 40543f2..77f8146 100644 --- a/client/src/pages/audit.tsx +++ b/client/src/pages/audit.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { ChevronDown, @@ -25,12 +25,14 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { getApiUrl, getCurrentDomain, apiFetch } from '@/lib/api'; +import { apiFetch } from '@/lib/api'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; import { Badge } from '@modl-gg/shared-web/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@modl-gg/shared-web/components/ui/select'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogPortal } from '@modl-gg/shared-web/components/ui/dialog'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@modl-gg/shared-web/components/ui/alert-dialog'; +import { Textarea } from '@modl-gg/shared-web/components/ui/textarea'; import { subDays } from 'date-fns'; import { formatDateOnly } from '@/utils/date-utils'; import { useLogs } from '@/hooks/use-data'; @@ -41,12 +43,17 @@ import { PermissionWrapper } from '@/components/PermissionWrapper'; import { PERMISSIONS } from '@/hooks/use-permissions'; import { StatusBanner } from '@modl-gg/shared-web/components/ui/status-banner'; import { cn } from '@modl-gg/shared-web/lib/utils'; +import { openExternalUrl } from '@/lib/utils'; import { usePlayerWindow } from '@/contexts/PlayerWindowContext'; import { useAuth } from '@/hooks/use-auth'; import { Checkbox } from '@modl-gg/shared-web/components/ui/checkbox'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line, Area, AreaChart } from 'recharts'; import { getEvidenceDisplayText, getEvidenceClickUrl, getEvidenceShortName, isEvidenceClickable } from '@/utils/evidence-utils'; import { formatTicketStatusLabel, normalizeTicketStatus } from '@/lib/ticket-enums'; +import { protoFetch } from '@/lib/proto-fetch'; +import { toNum, tsToDate } from '@/lib/proto-ui'; +import { StaffPerformanceListResponseSchema, ActivePunishmentsAuditResponseSchema } from '@modl-gg/proto/modl/v1/audit_pb.ts'; +import { AnalyticsOverviewResponseSchema } from '@modl-gg/proto/modl/v1/analytics_pb.ts'; interface StaffMember { id: string; @@ -163,57 +170,65 @@ const formatDurationDetailed = (date: Date) => { return parts.length > 0 ? parts.join(' ') : '0m'; }; -// API functions -const fetchAnalyticsOverview = async () => { - const response = await fetch(getApiUrl('/v1/panel/analytics/overview'), { - credentials: 'include', - headers: { 'X-Server-Domain': getCurrentDomain() } - }); - if (!response.ok) throw new Error('Failed to fetch analytics overview'); - return response.json(); +interface AnalyticsOverview { + totalTickets: number; + totalPlayers: number; + totalStaff: number; + activeTickets: number; + ticketChange: number; + playerChange: number; +} + +const fetchAnalyticsOverview = async (): Promise => { + const res = await protoFetch(AnalyticsOverviewResponseSchema, '/v1/panel/analytics/overview'); + const overview = res.overview['overview']; + return { + totalTickets: overview ? toNum(overview.totalTickets) : 0, + totalPlayers: overview ? toNum(overview.totalPlayers) : 0, + totalStaff: overview ? toNum(overview.totalStaff) : 0, + activeTickets: overview ? toNum(overview.activeTickets) : 0, + ticketChange: overview?.ticketChange ?? 0, + playerChange: overview?.playerChange ?? 0, + }; }; -const fetchStaffPerformance = async (period = '30d') => { - const response = await fetch(getApiUrl(`/v1/panel/audit/staff-performance?period=${period}`), { - credentials: 'include', - headers: { 'X-Server-Domain': getCurrentDomain() } - }); - if (!response.ok) throw new Error('Failed to fetch staff performance'); - return response.json(); +const fetchStaffPerformance = async (period = '30d'): Promise => { + const res = await protoFetch( + StaffPerformanceListResponseSchema, + `/v1/panel/audit/staff-performance?period=${period}`, + ); + return res.staff.map((member) => ({ + id: member.id, + username: member.username, + role: member.role, + totalActions: member.totalActions, + ticketResponses: member.ticketResponses, + punishmentsIssued: member.punishmentsIssued, + avgResponseTime: member.avgResponseTime, + lastActive: tsToDate(member.lastActive)?.toISOString() ?? '', + })); }; const fetchTicketAnalytics = async (period = '30d') => { - const response = await fetch(getApiUrl(`/v1/panel/analytics/tickets?period=${period}`), { - credentials: 'include', - headers: { 'X-Server-Domain': getCurrentDomain() } - }); + const response = await apiFetch(`/v1/panel/analytics/tickets?period=${period}`); if (!response.ok) throw new Error('Failed to fetch ticket analytics'); return response.json(); }; const fetchPunishmentAnalytics = async (period = '30d') => { - const response = await fetch(getApiUrl(`/v1/panel/analytics/punishments?period=${period}`), { - credentials: 'include', - headers: { 'X-Server-Domain': getCurrentDomain() } - }); + const response = await apiFetch(`/v1/panel/analytics/punishments?period=${period}`); if (!response.ok) throw new Error('Failed to fetch punishment analytics'); return response.json(); }; const fetchPlayerActivity = async (period = '30d') => { - const response = await fetch(getApiUrl(`/v1/panel/analytics/player-activity?period=${period}`), { - credentials: 'include', - headers: { 'X-Server-Domain': getCurrentDomain() } - }); + const response = await apiFetch(`/v1/panel/analytics/player-activity?period=${period}`); if (!response.ok) throw new Error('Failed to fetch player activity'); return response.json(); }; const fetchAuditLogsAnalytics = async (period = '7d') => { - const response = await fetch(getApiUrl(`/v1/panel/analytics/audit-logs?period=${period}`), { - credentials: 'include', - headers: { 'X-Server-Domain': getCurrentDomain() } - }); + const response = await apiFetch(`/v1/panel/analytics/audit-logs?period=${period}`); if (!response.ok) throw new Error('Failed to fetch audit logs analytics'); return response.json(); }; @@ -239,12 +254,34 @@ interface ActivePunishment { } const fetchPunishmentsList = async (status: string): Promise => { - const response = await fetch(getApiUrl(`/v1/panel/audit/punishments/active?status=${status}`), { - credentials: 'include', - headers: { 'X-Server-Domain': getCurrentDomain() } - }); - if (!response.ok) throw new Error('Failed to fetch punishments'); - return response.json(); + const res = await protoFetch( + ActivePunishmentsAuditResponseSchema, + `/v1/panel/audit/punishments/active?status=${status}`, + ); + return res.punishments.map((punishment) => ({ + id: punishment.id, + playerId: punishment.playerId, + playerName: punishment.playerName, + type: punishment.type, + typeOrdinal: punishment.typeOrdinal, + category: punishment.category, + staffName: punishment.staffName, + reason: punishment.reason, + duration: punishment.duration !== undefined ? toNum(punishment.duration) : null, + issued: tsToDate(punishment.issued)?.toISOString() ?? '', + started: tsToDate(punishment.started)?.toISOString() ?? null, + expires: tsToDate(punishment.expires)?.toISOString() ?? null, + active: punishment.active, + hasEvidence: punishment.hasEvidence, + evidenceCount: punishment.evidenceCount, + evidence: punishment.evidence.map((item) => ({ + text: item.text, + url: item.url, + type: item.type, + fileName: item.fileName, + })), + attachedTicketIds: punishment.attachedTicketIds, + })); }; // Custom themed tooltip component for charts @@ -254,7 +291,7 @@ const CustomTooltip = ({ active, payload, label, formatValue, formatName }: any)
{label &&

{label}

} {payload.map((entry: any, index: number) => ( -
+
{ @@ -481,7 +519,7 @@ const BulkPunishmentActionsModal = ({ activePunishments, onSuccess }: { return durationValue * (multipliers[durationUnit] || multipliers.days); }; - const handleSubmit = async () => { + const handleSubmit = () => { if (selectedTypes.size === 0) { toast({ title: 'No types selected', description: 'Select at least one punishment type.', variant: 'destructive' }); return; @@ -490,12 +528,11 @@ const BulkPunishmentActionsModal = ({ activePunishments, onSuccess }: { toast({ title: 'Reason required', description: 'Please provide a reason.', variant: 'destructive' }); return; } + setConfirmOpen(true); + }; - const actionLabel = operation === 'pardon' ? 'pardon' : 'set expiration on'; - if (!window.confirm(`Are you sure you want to ${actionLabel} ${previewCount} punishment(s)? This cannot be undone.`)) { - return; - } - + const performSubmit = async () => { + setConfirmOpen(false); setIsSubmitting(true); try { const endpoint = operation === 'pardon' @@ -667,6 +704,25 @@ const BulkPunishmentActionsModal = ({ activePunishments, onSuccess }: {
+ + + + Confirm Bulk Action + + {`Are you sure you want to ${operation === 'pardon' ? 'pardon' : 'set expiration on'} ${previewCount} punishment(s)? This cannot be undone.`} + + + + Cancel + + Confirm + + + + ); }; @@ -679,6 +735,13 @@ const StaffDetailModal = ({ staff, isOpen, onClose, initialPeriod = '30d' }: { initialPeriod?: string }) => { const [selectedPeriod, setSelectedPeriod] = useState(initialPeriod); + const [rollbackDialog, setRollbackDialog] = useState<{ open: boolean; punishmentId: string | null; summary: string; reason: string; submitting: boolean }>({ + open: false, + punishmentId: null, + summary: '', + reason: '', + submitting: false, + }); const { toast } = useToast(); const { t } = useTranslation(); const { openPlayerWindow, windows } = usePlayerWindow(); @@ -710,6 +773,78 @@ const StaffDetailModal = ({ staff, isOpen, onClose, initialPeriod = '30d' }: { const recentTickets = staffDetails?.tickets || []; const evidenceCount = staffDetails?.evidenceUploads || 0; + const openRollbackDialog = useCallback((punishmentId: string, summary: string) => { + setRollbackDialog({ open: true, punishmentId, summary, reason: '', submitting: false }); + }, []); + + const handleConfirmRollback = useCallback(async () => { + const { punishmentId, reason } = rollbackDialog; + if (!punishmentId || !reason.trim()) return; + setRollbackDialog(prev => ({ ...prev, submitting: true })); + try { + const response = await apiFetch(`/v1/panel/audit/punishments/${punishmentId}/rollback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: reason.trim() }) + }); + + const responseData = await response.json(); + + if (!response.ok) { + throw new Error(responseData.error || `HTTP ${response.status}: ${response.statusText}`); + } + + toast({ + title: t('audit.punishmentRolledBack'), + description: t('audit.punishmentRolledBackSuccess', { message: responseData.message }) + }); + setRollbackDialog({ open: false, punishmentId: null, summary: '', reason: '', submitting: false }); + refetch(); + } catch (error) { + console.error('Rollback error:', error); + toast({ + title: t('audit.rollbackFailed'), + description: t('audit.rollbackFailedWithError', { error: error instanceof Error ? error.message : t('audit.unknownError') }), + variant: 'destructive' + }); + setRollbackDialog(prev => ({ ...prev, submitting: false })); + } + }, [rollbackDialog, refetch, toast, t]); + + // Format helpers hoisted out of the .map for the recent punishments table + const formatRecentPunishmentDuration = useCallback((duration: any): string => { + const durationNum = typeof duration === 'number' ? duration : Number(duration); + if (!durationNum || durationNum === -1 || isNaN(durationNum)) return t('audit.permanent'); + + const days = Math.floor(durationNum / (1000 * 60 * 60 * 24)); + const hours = Math.floor((durationNum % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((durationNum % (1000 * 60 * 60)) / (1000 * 60)); + + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + }, [t]); + + const getRecentPunishmentStatus = useCallback((punishment: any) => { + const hasPardon = punishment.modifications?.some((mod: any) => + mod.type === 'MANUAL_PARDON' || + mod.type === 'APPEAL_ACCEPT' || + mod.type === 'Pardoned' || + mod.type === 'Appeal Accepted' || + mod.type === 'Appeal Approved' + ); + + if (hasPardon || punishment.rolledBack) { + return { status: t('audit.pardoned'), isActive: false, variant: 'outline' as const, color: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 border-green-300 dark:border-green-700' }; + } + + if (punishment.active === false) { + return { status: t('status.inactive'), isActive: false, variant: 'outline' as const, color: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-700' }; + } + + return { status: t('status.active'), isActive: true, variant: 'outline' as const, color: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 border-red-300 dark:border-red-700' }; + }, [t]); + return ( {recentPunishments.length > 0 ? recentPunishments.map((punishment: any, index: number) => { - // Format duration helper function - const formatDuration = (duration: any) => { - const durationNum = typeof duration === 'number' ? duration : Number(duration); - if (!durationNum || durationNum === -1 || isNaN(durationNum)) return t('audit.permanent'); - - const days = Math.floor(durationNum / (1000 * 60 * 60 * 24)); - const hours = Math.floor((durationNum % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); - const minutes = Math.floor((durationNum % (1000 * 60 * 60)) / (1000 * 60)); - - if (days > 0) return `${days}d ${hours}h`; - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; - }; - - // Determine punishment status - const getPunishmentStatus = (punishment: any) => { - // Check for pardoned/appeal accepted modifications - const hasPardon = punishment.modifications?.some((mod: any) => - mod.type === 'MANUAL_PARDON' || - mod.type === 'APPEAL_ACCEPT' || - mod.type === 'Pardoned' || - mod.type === 'Appeal Accepted' || - mod.type === 'Appeal Approved' - ); - - if (hasPardon || punishment.rolledBack) { - return { status: t('audit.pardoned'), isActive: false, variant: 'outline' as const, color: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 border-green-300 dark:border-green-700' }; - } - - if (punishment.active === false) { - return { status: t('status.inactive'), isActive: false, variant: 'outline' as const, color: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-700' }; - } - - return { status: t('status.active'), isActive: true, variant: 'outline' as const, color: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 border-red-300 dark:border-red-700' }; - }; - - const statusInfo = getPunishmentStatus(punishment); + const statusInfo = getRecentPunishmentStatus(punishment); + const punishmentRowKey = punishment.id || `recent-punishment-${index}`; return ( - +
- {formatDuration(punishment.duration)} + {formatRecentPunishmentDuration(punishment.duration)} {formatDateOnly(new Date(punishment.issued))}
@@ -1012,39 +1112,10 @@ const StaffDetailModal = ({ staff, isOpen, onClose, initialPeriod = '30d' }: { variant="outline" size="sm" className="text-xs h-5 px-1" - onClick={async () => { - try { - - const csrfFetch = apiFetch; - const response = await csrfFetch(`/v1/panel/audit/punishment/${punishment.id}/rollback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ reason: 'Staff rollback from analytics panel' }) - }); - - const responseData = await response.json(); - - if (!response.ok) { - throw new Error(responseData.error || `HTTP ${response.status}: ${response.statusText}`); - } - - // Show success message - toast({ - title: t('audit.punishmentRolledBack'), - description: t('audit.punishmentRolledBackSuccess', { message: responseData.message }) - }); - - // Refresh the modal data - refetch(); - } catch (error) { - console.error('Rollback error:', error); - toast({ - title: t('audit.rollbackFailed'), - description: t('audit.rollbackFailedWithError', { error: error instanceof Error ? error.message : t('audit.unknownError') }), - variant: "destructive" - }); - } - }} + onClick={() => openRollbackDialog( + punishment.id, + `${punishment.type || t('audit.unknown')} — ${punishment.playerName || t('audit.unknown')}` + )} title={t('audit.rollbackThisPunishment')} > @@ -1086,14 +1157,18 @@ const StaffDetailModal = ({ staff, isOpen, onClose, initialPeriod = '30d' }: { {recentTickets && recentTickets.length > 0 ? recentTickets.map((ticket: any, index: number) => { - const timeSinceOpened = formatDurationDetailed(new Date(ticket.created || ticket.createdAt || ticket.timestamp)); - const timeSinceLastActivity = ticket.lastActivity ? formatDurationDetailed(new Date(ticket.lastActivity)) : + // StaffDetailsResponse.TicketDetail exposes no opened/created timestamp, + // so only render a duration when such a field is actually present (avoids a misleading "0m"). + const openedRaw = ticket.created || ticket.createdAt || ticket.timestamp; + const timeSinceOpened = openedRaw ? formatDurationDetailed(new Date(openedRaw)) : '--'; + const timeSinceLastActivity = ticket.lastActivity ? formatDurationDetailed(new Date(ticket.lastActivity)) : ticket.updatedAt ? formatDurationDetailed(new Date(ticket.updatedAt)) : '--'; const normalizedStatus = normalizeTicketStatus(ticket.status); const statusLabel = formatTicketStatusLabel(ticket.status); - + const ticketRowKey = ticket.ticketId || ticket.id || `recent-ticket-${index}`; + return ( - +
+ { + if (!o) setRollbackDialog({ open: false, punishmentId: null, summary: '', reason: '', submitting: false }); + }} + > + + + {t('audit.rollbackThisPunishment')} + {rollbackDialog.summary} + +
+ +