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/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf 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/.gitignore b/.gitignore index 031101b..6507eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,7 @@ Icon !.gitignore !.git/ !.github/ +!.gitattributes # Files that might appear in the root of a volume .DocumentRevisions-V100 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/public/_headers b/client/public/_headers index 3258f0e..c7efc48 100644 --- a/client/public/_headers +++ b/client/public/_headers @@ -2,3 +2,6 @@ X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin + +/assets/* + Cache-Control: public, max-age=31536000, immutable diff --git a/client/src/App.tsx b/client/src/App.tsx index 4ec6673..5abdb09 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -12,6 +12,8 @@ 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 { useLanguagePreference } from "@/hooks/use-language-preference"; +import { RealtimeProvider } from "@/hooks/use-realtime"; import { PlayerWindowProvider } from "@/contexts/PlayerWindowContext"; import { WelcomeModal } from "@/components/layout/WelcomeModal"; import { Loader2 } from "lucide-react"; @@ -66,8 +68,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 +85,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 +102,7 @@ function Router() { +
@@ -186,9 +190,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); @@ -197,6 +212,7 @@ function AppContent() { useDocumentTitle(); useProvisioningStatusCheck(); + useLanguagePreference(); useEffect(() => { const hasSeenModal = localStorage.getItem("hasSeenWelcomeModal"); @@ -216,7 +232,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 +247,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 +278,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..fad6cbe 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 { Dialog, DialogContent, DialogDescription, 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; + }); } }; @@ -152,6 +169,9 @@ export function EvidenceUpload({ {item.fileName} + + {t('common.attachmentPreviewDesc')} +
{isImage(item.fileType) ? ( @@ -184,13 +204,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..83dbcb0 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'; @@ -19,7 +19,7 @@ interface MediaUploadProps { maxFiles?: number; acceptedTypes?: string[]; maxSizeBytes?: number; - metadata?: Record; + metadata?: Record; disabled?: boolean; className?: string; variant?: 'default' | 'compact' | 'button-only'; @@ -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/PermissionWrapper.tsx b/client/src/components/PermissionWrapper.tsx index 30ce156..68e53c3 100644 --- a/client/src/components/PermissionWrapper.tsx +++ b/client/src/components/PermissionWrapper.tsx @@ -1,11 +1,11 @@ -import { ReactNode } from 'react'; -import { usePermissions } from '@/hooks/use-permissions'; +import type { ReactNode } from 'react'; +import { usePermissions, type SettingsTab } from '@/hooks/use-permissions'; import { useTranslation } from 'react-i18next'; interface PermissionWrapperProps { children: ReactNode; permissions?: string[]; - settingsTab?: string; + settingsTab?: SettingsTab; fallback?: ReactNode; } @@ -18,8 +18,8 @@ export function PermissionWrapper({ const { hasAllPermissions, canAccessSettingsTab } = usePermissions(); // Check permissions based on provided criteria - const hasAccess = settingsTab - ? canAccessSettingsTab(settingsTab as any) + const hasAccess = settingsTab + ? canAccessSettingsTab(settingsTab) : (permissions && permissions.length === 0) || hasAllPermissions(permissions || []); return hasAccess ? <>{children} : <>{fallback}; @@ -29,14 +29,14 @@ export function PermissionWrapper({ export function withPermissionCheck( Component: React.ComponentType, requiredPermissions: string[] = [], - settingsTab?: string + settingsTab?: SettingsTab ) { return function PermissionCheckedComponent(props: T) { const { t } = useTranslation(); const { hasAllPermissions, canAccessSettingsTab } = usePermissions(); const hasAccess = settingsTab - ? canAccessSettingsTab(settingsTab as any) + ? canAccessSettingsTab(settingsTab) : (requiredPermissions && requiredPermissions.length === 0) || hasAllPermissions(requiredPermissions || []); if (!hasAccess) { diff --git a/client/src/components/TicketAttachments.tsx b/client/src/components/TicketAttachments.tsx index bb2b79c..1f4ce33 100644 --- a/client/src/components/TicketAttachments.tsx +++ b/client/src/components/TicketAttachments.tsx @@ -1,14 +1,16 @@ 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 { Dialog, DialogContent, DialogDescription, 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 { normalizeCdnHost, isTrustedCdnUrl as isUrlOnTrustedCdn } from '@/utils/evidence-utils'; interface TicketAttachment { id: string; @@ -43,6 +45,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 +54,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 +64,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 +90,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 +111,12 @@ export function TicketAttachments({ description: t('upload.deleteAttachmentFailed'), variant: "destructive", }); + } finally { + setDeletingIds(prev => { + const next = new Set(prev); + next.delete(attachment.id); + return next; + }); } }; @@ -115,26 +131,9 @@ export function TicketAttachments({ const isVideo = (type: string) => type.startsWith('video/'); const isPdf = (type: string) => type === 'application/pdf'; - const normalizedCdnHost = config?.cdnDomain - ? (() => { - const raw = config.cdnDomain.trim(); - try { - const parsed = new URL(raw.startsWith('http') ? raw : `https://${raw}`); - return parsed.hostname.toLowerCase(); - } catch { - return raw.replace(/^https?:\/\//i, '').split('/')[0].toLowerCase(); - } - })() - : null; + const normalizedCdnHost = normalizeCdnHost(config?.cdnDomain); - const isTrustedCdnUrl = (url: string) => { - if (!normalizedCdnHost) return false; - try { - return new URL(url).hostname.toLowerCase() === normalizedCdnHost; - } catch { - return false; - } - }; + const isTrustedCdnUrl = (url: string) => isUrlOnTrustedCdn(url, normalizedCdnHost); if (!config?.backblazeConfigured) { return ( @@ -181,13 +180,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')} + + + + + ) )} ); @@ -220,6 +258,9 @@ export function TicketAttachments({ {attachment.fileName} + + {t('common.attachmentPreviewDesc')} +
{isImage(attachment.fileType) && isTrustedCdnUrl(attachment.url) ? ( @@ -265,14 +306,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..072d1f6 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 ( @@ -125,8 +107,8 @@ export function RecentPunishmentsSection({ punishments, loading }: RecentPunishm )} {!punishment.active && ( - - EXPIRED + + {t('status.inactive')} )}
@@ -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) => ( void; onRefresh?: () => void; isRefreshing?: boolean; @@ -30,6 +31,7 @@ const ResizableWindow = ({ isOpen, initialPosition = { x: '50%', y: '50%' }, initialSize = { width: 600, height: 500 }, + minSize = { width: 300, height: 200 }, onClose, onRefresh, isRefreshing, @@ -38,75 +40,87 @@ const ResizableWindow = ({ const windowRef = useRef(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 }); - const [windowOffset, setWindowOffset] = useState({ x: 0, y: 0 }); const [windowBeforeMinimize, setWindowBeforeMinimize] = useState<{ position: WindowPosition; size: { width: number; height: number }; }>({ 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 +128,7 @@ const ResizableWindow = ({ lastPlayerWindowConfig.position = { x: xPos, y: yPos }; } } - }, [isOpen, initialPosition, isInitialized, id]); + }, [isOpen, isInitialized, id]); // Handle dragging useEffect(() => { @@ -138,14 +152,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 +173,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; @@ -172,29 +186,6 @@ const ResizableWindow = ({ }); }; - const handleMaximize = () => { - if (isMinimized) { - // If minimized, restore to normal first - handleMinimize(); - } - - if (isMaximized) { - // Restore - setIsMaximized(false); - setPosition({ x: windowOffset.x, y: windowOffset.y }); - setSize(initialSize); - } else { - // Maximize - setIsMaximized(true); - setWindowOffset({ - x: typeof position.x === 'number' ? position.x : 0, - y: typeof position.y === 'number' ? position.y : 0 - }); - setPosition({ x: 0, y: 0 }); - setSize({ width: window.innerWidth, height: window.innerHeight }); - } - }; - const handleMinimize = () => { if (isMinimized) { // Restore from minimized state @@ -260,14 +251,14 @@ const ResizableWindow = ({ const handleMouseMove = (moveEvent: MouseEvent) => { // Handle right side resizing if (direction.includes('right')) { - const newWidth = Math.max(300, startWidth + (moveEvent.clientX - startX)); + const newWidth = Math.max(minSize.width, startWidth + (moveEvent.clientX - startX)); setSize(prev => ({ ...prev, width: newWidth })); } - + // Handle left side resizing if (direction.includes('left')) { const deltaX = startX - moveEvent.clientX; - if (startWidth + deltaX >= 300) { + if (startWidth + deltaX >= minSize.width) { setSize(prev => ({ ...prev, width: startWidth + deltaX })); setPosition(prev => ({ ...prev, @@ -278,14 +269,14 @@ const ResizableWindow = ({ // Handle bottom side resizing if (direction.includes('bottom')) { - const newHeight = Math.max(200, startHeight + (moveEvent.clientY - startY)); + const newHeight = Math.max(minSize.height, startHeight + (moveEvent.clientY - startY)); setSize(prev => ({ ...prev, height: newHeight })); } - + // Handle top side resizing if (direction.includes('top')) { const deltaY = startY - moveEvent.clientY; - if (startHeight + deltaY >= 200) { + if (startHeight + deltaY >= minSize.height) { setSize(prev => ({ ...prev, height: startHeight + deltaY })); setPosition(prev => ({ ...prev, diff --git a/client/src/components/layout/Sidebar.tsx b/client/src/components/layout/Sidebar.tsx index c4e9d6e..cdaa825 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,35 +19,57 @@ 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?: { isOnline?: boolean }; +} + +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 { 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(""); - const [isFocused, setIsFocused] = useState(false); + const [, setIsFocused] = useState(false); const [isHoveringSearch, setIsHoveringSearch] = useState(false); const [recentSearches, setRecentSearches] = 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; + }, 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; @@ -381,6 +423,7 @@ const Sidebar = () => {
@@ -601,9 +645,9 @@ const Sidebar = () => { {recentSearches .sort((a, b) => b.timestamp - a.timestamp) .slice(0, 5) - .map(({player}, index) => ( + .map(({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..45664d5 --- /dev/null +++ b/client/src/components/player-replays-list.tsx @@ -0,0 +1,127 @@ +import { Loader2, Play, TriangleAlert } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +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 { 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..606ae26 100644 --- a/client/src/components/settings/AccountSettings.tsx +++ b/client/src/components/settings/AccountSettings.tsx @@ -1,7 +1,17 @@ import React, { useState, useEffect } from 'react'; -import { LogOut, Monitor, Smartphone } from 'lucide-react'; +import { Loader2, LogOut, Monitor, Smartphone } from 'lucide-react'; import PasskeySettings from './PasskeySettings'; import { Button } from '@modl-gg/shared-web/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@modl-gg/shared-web/components/ui/alert-dialog'; import { Input } from '@modl-gg/shared-web/components/ui/input'; import { Label } from '@modl-gg/shared-web/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@modl-gg/shared-web/components/ui/select'; @@ -9,6 +19,7 @@ import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import { useAuth } from '@/hooks/use-auth'; import { useTranslation } from 'react-i18next'; import { apiFetch } from '@/lib/api'; +import { SUPPORTED_LANGUAGES } from '@/lib/languages'; interface AccountSettingsProps { profileUsername: string; @@ -51,29 +62,56 @@ function isMobileUserAgent(ua: string): boolean { return /Android|iPhone|iPad|Mobile/.test(ua); } -function SessionsSection({ onSignOutAll }: { onSignOutAll: () => void }) { +function SessionsSection() { const { t } = useTranslation(); const { toast } = useToast(); + const { logout, signOutAllSessions } = useAuth(); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); - const [signingOut, setSigningOut] = useState(false); + const [loadError, setLoadError] = useState(false); + const [pendingSession, setPendingSession] = useState(null); + const [removingId, setRemovingId] = useState(null); + const [signAllOutOpen, setSignAllOutOpen] = 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 () => { - setSigningOut(true); + const confirmRemoveSession = async () => { + const session = pendingSession; + setPendingSession(null); + if (!session) return; + + if (session.isCurrent) { + logout(); + return; + } + + setRemovingId(session.id); try { - await apiFetch('/v1/panel/auth/logout', { method: 'POST' }); - onSignOutAll(); + const response = await apiFetch(`/v1/panel/auth/sessions/${encodeURIComponent(session.id)}`, { method: 'DELETE' }); + if (!response.ok && response.status !== 404) throw new Error('Failed to remove session'); + setSessions(prev => prev.filter(s => s.id !== session.id)); + if (response.ok) { + toast({ title: t('settings.sessions.removed') }); + } } catch { - toast({ title: t('common.error'), variant: 'destructive' }); - setSigningOut(false); + toast({ title: t('settings.sessions.removeFailed'), variant: 'destructive' }); + } finally { + setRemovingId(null); } }; @@ -81,13 +119,15 @@ function SessionsSection({ onSignOutAll }: { onSignOutAll: () => void }) {
-

Sessions

+

{t('settings.sessions.title')}

{loading ? ( -

Loading…

+

{t('settings.sessions.loading')}

+ ) : loadError ? ( +

{t('settings.sessions.loadFailed')}

) : sessions.length === 0 ? ( -

No active sessions.

+

{t('settings.sessions.empty')}

) : (
{sessions.map((s) => { @@ -105,13 +145,24 @@ function SessionsSection({ onSignOutAll }: { onSignOutAll: () => void }) {

{browser} on {os} {s.isCurrent && ( - Current + {t('settings.sessions.current')} )}

{s.ipAddress ?? 'Unknown IP'} · Added {added}

+
); })} @@ -121,12 +172,42 @@ function SessionsSection({ onSignOutAll }: { onSignOutAll: () => void }) { + + { if (!open) setPendingSession(null); }}> + + + {t('settings.sessions.removeTitle')} + + {pendingSession?.isCurrent + ? t('settings.sessions.removeCurrentDescription') + : t('settings.sessions.removeDescription')} + + + + {t('common.cancel')} + {t('settings.sessions.removeAction')} + + + + + + + + {t('settings.sessions.signAllOutTitle')} + {t('settings.sessions.signAllOutDescription')} + + + {t('common.cancel')} + signOutAllSessions()}>{t('settings.sessions.signAllOut')} + + + ); } @@ -169,7 +250,7 @@ const AccountSettings = ({ }); } catch (error) { toast({ - title: t('common.error'), + title: t('toast.error'), description: error instanceof Error ? error.message : t('settings.emailUpdateFailed'), variant: 'destructive', }); @@ -198,7 +279,7 @@ const AccountSettings = ({ setTimeout(() => window.location.reload(), 1000); } catch (error) { toast({ - title: t('common.error'), + title: t('toast.error'), description: error instanceof Error ? error.message : t('settings.emailUpdateFailed'), variant: 'destructive', }); @@ -282,21 +363,26 @@ const AccountSettings = ({ )} {emailChangeStep === 'code-sent' && ( -
- setEmailCode(e.target.value)} - placeholder="Enter code" - className="max-w-[160px]" - onKeyDown={(e) => { if (e.key === 'Enter') handleConfirmEmailChange(); }} - /> - - +
+
+ setEmailCode(e.target.value)} + placeholder="Enter code" + className="max-w-[160px]" + onKeyDown={(e) => { if (e.key === 'Enter') handleConfirmEmailChange(); }} + /> + + +
+

+ {t('settings.emailChangeWarning')} +

)}

@@ -313,10 +399,9 @@ const AccountSettings = ({ - English - Deutsch - Español - Nederlands + {SUPPORTED_LANGUAGES.map((language) => ( + {language.nativeName} + ))}

@@ -348,7 +433,7 @@ const AccountSettings = ({ {/* MIDDLE: passkeys + sessions */}

- +
{/* RIGHT: small offset column aligned with sign-out button */} diff --git a/client/src/components/settings/ArticleListItem.tsx b/client/src/components/settings/ArticleListItem.tsx index 92bf715..1a31fc4 100644 --- a/client/src/components/settings/ArticleListItem.tsx +++ b/client/src/components/settings/ArticleListItem.tsx @@ -1,9 +1,10 @@ import React, { useRef } from 'react'; -import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd'; +import { useDrag, useDrop, type DropTargetMonitor } from 'react-dnd'; +import type { Identifier } from 'dnd-core'; import { Card } from '@modl-gg/shared-web/components/ui/card'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { GripVertical, Edit, Trash2, Eye, EyeOff } from 'lucide-react'; -import { KnowledgebaseArticle } from '@modl-gg/shared-web/types'; +import { type KnowledgebaseArticleStub } from '@modl-gg/shared-web/types'; export const ItemTypes = { ARTICLE: 'article', @@ -17,11 +18,11 @@ interface ArticleDragItem { } interface ArticleListItemProps { - article: KnowledgebaseArticle; + article: KnowledgebaseArticleStub; index: number; categoryId: string; moveArticle: (categoryId: string, dragIndex: number, hoverIndex: number) => void; - onEdit: (article: KnowledgebaseArticle) => void; + onEdit: (article: KnowledgebaseArticleStub) => void; onDelete: (categoryId: string, articleId: string, title: string) => void; onDropArticle: (categoryId: string) => void; } @@ -37,7 +38,7 @@ const ArticleListItem: React.FC = ({ }) => { const ref = useRef(null); - const [{ handlerId }, drop] = useDrop({ + const [{ handlerId }, drop] = useDrop({ accept: ItemTypes.ARTICLE, collect(monitor) { return { diff --git a/client/src/components/settings/AssignMinecraftPlayerModal.tsx b/client/src/components/settings/AssignMinecraftPlayerModal.tsx index 32b8fb5..1a06b9f 100644 --- a/client/src/components/settings/AssignMinecraftPlayerModal.tsx +++ b/client/src/components/settings/AssignMinecraftPlayerModal.tsx @@ -29,7 +29,7 @@ const AssignMinecraftPlayerModal: React.FC = ({ staffMember }) => { const { t } = useTranslation(); - const [selectedPlayerUuid, setSelectedPlayerUuid] = useState(''); + const [selectedPlayerUuid, setSelectedPlayerUuid] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const { toast } = useToast(); @@ -41,17 +41,14 @@ const AssignMinecraftPlayerModal: React.FC = ({ const assignedUuids = useMemo(() => { if (!staff) return []; return staff - .filter((member: any) => member.assignedMinecraftUuid) - .map((member: any) => member.assignedMinecraftUuid); + .filter((member) => member.assignedMinecraftUuid) + .map((member) => member.assignedMinecraftUuid); }, [staff]); // Filter out already assigned players from search results const availablePlayers = useMemo(() => { if (!searchResults) return []; - return searchResults.filter((player: any) => { - const playerUuid = player.uuid || player.minecraftUuid; - return !assignedUuids.includes(playerUuid); - }); + return searchResults.filter((player) => !assignedUuids.includes(player.uuid)); }, [searchResults, assignedUuids]); const handleAssign = async () => { @@ -66,16 +63,13 @@ const AssignMinecraftPlayerModal: React.FC = ({ return; } - const selectedPlayer = availablePlayers.find((p: any) => { - const playerUuid = p.uuid || p.minecraftUuid; - return playerUuid === selectedPlayerUuid; - }); + const selectedPlayer = availablePlayers.find((p) => p.uuid === selectedPlayerUuid); if (!selectedPlayer) return; try { await assignPlayerMutation.mutateAsync({ - username: staffMember.username, - minecraftUuid: selectedPlayer.uuid || selectedPlayer.minecraftUuid, + email: staffMember.email, + minecraftUuid: selectedPlayer.uuid, minecraftUsername: selectedPlayer.username }); @@ -100,7 +94,7 @@ const AssignMinecraftPlayerModal: React.FC = ({ try { await assignPlayerMutation.mutateAsync({ - username: staffMember.username, + email: staffMember.email, minecraftUuid: undefined, minecraftUsername: undefined }); @@ -207,8 +201,8 @@ const AssignMinecraftPlayerModal: React.FC = ({
{t('settings.staff.foundAvailablePlayers', { count: availablePlayers.length })}
- {availablePlayers.map((player: any) => { - const playerUuid = player.uuid || player.minecraftUuid; + {availablePlayers.map((player) => { + const playerUuid = player.uuid; return ( + } + > + {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 +
+ { if (v !== undefined) 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')} +
+ { if (v !== undefined) 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: TFunction; + 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(); const resubscribeMutation = useResubscribe(); + const checkoutSessionMutation = useCreateCheckoutSession(); + const portalSessionMutation = useCreatePortalSession(); const { toast } = useToast(); const { t } = useTranslation(); - const [isLoading, setIsLoading] = useState(false); - const [isSpinning, setIsSpinning] = useState(false); const queryClient = useQueryClient(); + const [isRedirectingToStripe, setIsRedirectingToStripe] = useState(false); const handleCreateCheckoutSession = async () => { - setIsLoading(true); + setIsRedirectingToStripe(true); try { - const response = await apiFetch('/v1/panel/billing/checkout-session', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - } - }); - - if (!response.ok) { - throw new Error('Failed to create checkout session'); - } - - const data = await response.json(); + const session = await checkoutSessionMutation.mutateAsync(); - // Prefer using the URL directly (modern approach) - if (data.url) { - window.location.href = data.url; + if (session.url) { + window.location.href = session.url; return; } - // Fallback to redirectToCheckout if no URL provided - if (data.sessionId) { + if (session.sessionId) { const stripe = await getStripe(); if (!stripe) { + setIsRedirectingToStripe(false); toast({ title: t('settings.billing.configurationError'), description: t('settings.billing.stripeNotConfigured'), @@ -145,72 +526,55 @@ const BillingSettings = () => { }); return; } - const { error } = await stripe.redirectToCheckout({ sessionId: data.sessionId }); + const { error } = await stripe.redirectToCheckout({ sessionId: session.sessionId }); if (error) { + setIsRedirectingToStripe(false); toast({ title: t('toast.error'), description: error.message || t('settings.billing.stripeRedirectFailed'), variant: 'destructive', }); } - } else { - throw new Error('No checkout URL or session ID returned from server'); + return; } - } catch (error) { + + setIsRedirectingToStripe(false); toast({ title: t('toast.error'), description: t('settings.billing.checkoutSessionFailed'), variant: 'destructive', }); - } finally { - setIsLoading(false); - } - }; - - const handleCreatePortalSession = async () => { - setIsLoading(true); - try { - const response = await apiFetch('/v1/panel/billing/portal-session', { - method: 'POST', - }); - - if (!response.ok) { - throw new Error('Failed to create portal session'); - } - - const data = await response.json(); - if (!data.url) { - throw new Error('No portal URL returned from server'); - } - window.location.href = data.url; } catch (error) { + setIsRedirectingToStripe(false); toast({ title: t('toast.error'), - description: t('settings.billing.portalFailed'), + description: protoErrorMessage(error, t('settings.billing.checkoutSessionFailed')), variant: 'destructive', }); - } finally { - setIsLoading(false); } }; - const handleRefreshBillingStatus = async () => { - setIsSpinning(true); + const handleCreatePortalSession = async () => { + setIsRedirectingToStripe(true); try { - await queryClient.invalidateQueries({ queryKey: ['/v1/panel/billing/status'] }); - - toast({ - title: t('settings.billing.refreshed'), - description: t('settings.billing.refreshedDesc'), - }); + const session = await portalSessionMutation.mutateAsync(); + if (!session.url) { + setIsRedirectingToStripe(false); + toast({ + title: t('toast.error'), + description: t('settings.billing.portalFailed'), + variant: 'destructive', + }); + return; + } + window.location.href = session.url; } catch (error) { + setIsRedirectingToStripe(false); toast({ - variant: 'destructive', title: t('toast.error'), - description: t('settings.billing.refreshFailed'), + description: protoErrorMessage(error, t('settings.billing.portalFailed')), + variant: 'destructive', }); - } finally { - setIsSpinning(false); } }; @@ -223,10 +587,10 @@ const BillingSettings = () => { description: response.message || t('settings.billing.subscriptionCancelledDesc'), variant: 'default', }); - } catch (error: any) { + } catch (error) { toast({ title: t('toast.error'), - description: error.message || t('settings.billing.cancelFailed'), + description: errorMessageOr(error, t('settings.billing.cancelFailed')), variant: 'destructive', }); } @@ -241,10 +605,10 @@ const BillingSettings = () => { description: response.message || t('settings.billing.subscriptionReactivatedDesc'), variant: 'default', }); - } catch (error: any) { + } catch (error) { toast({ title: t('toast.error'), - description: error.message || t('settings.billing.reactivateFailed'), + description: errorMessageOr(error, t('settings.billing.reactivateFailed')), variant: 'destructive', }); } @@ -271,12 +635,6 @@ const BillingSettings = () => { return ['ACTIVE', 'TRIALING', 'PAST_DUE', 'UNPAID', 'CANCELED'].includes(normalizedStatus); }; - const needsPaymentAttention = () => { - if (!billingStatus) return false; - const normalizedStatus = normalizeSubscriptionStatus(billingStatus.subscriptionStatus); - return normalizedStatus === 'PAST_DUE' || normalizedStatus === 'UNPAID'; - }; - const getSubscriptionAlert = () => { if (isBillingLoading || !billingStatus) return null; @@ -322,22 +680,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 +703,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 +723,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/ChangeRoleModal.tsx b/client/src/components/settings/ChangeRoleModal.tsx index 0eef8c2..4a88336 100644 --- a/client/src/components/settings/ChangeRoleModal.tsx +++ b/client/src/components/settings/ChangeRoleModal.tsx @@ -91,10 +91,10 @@ const ChangeRoleModal: React.FC = ({ isOpen, onClose, staf // Refresh the staff list queryClient.invalidateQueries({ queryKey: ['/v1/panel/staff'] }); onClose(); - } catch (error: any) { + } catch (error) { toast({ title: t('toast.error'), - description: error.message, + description: error instanceof Error ? error.message : undefined, variant: 'destructive', }); } diff --git a/client/src/components/settings/DomainSettings.tsx b/client/src/components/settings/DomainSettings.tsx index 46aaf86..5047cb4 100644 --- a/client/src/components/settings/DomainSettings.tsx +++ b/client/src/components/settings/DomainSettings.tsx @@ -1,13 +1,14 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Globe, CheckCircle, Copy, ExternalLink, RefreshCw, Check, Crown } from 'lucide-react'; +import { CheckCircle, Copy, ExternalLink, RefreshCw, Check, Crown } from 'lucide-react'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; 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'; @@ -41,8 +42,9 @@ const DomainSettings: React.FC = () => { useEffect(() => { const hostname = getCurrentDomain(); const parts = hostname.split('.'); - if (parts.length > 2) { - setCurrentDomain(parts[0]); + const [subdomain] = parts; + if (parts.length > 2 && subdomain !== undefined) { + setCurrentDomain(subdomain); } }, []); @@ -278,13 +280,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 +394,9 @@ const DomainSettings: React.FC = () => {
- + {getStatusText(domainStatus.status)} - + {domainStatus.domain}
@@ -421,14 +423,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..1949035 100644 --- a/client/src/components/settings/EmbedTemplateEditor.tsx +++ b/client/src/components/settings/EmbedTemplateEditor.tsx @@ -6,7 +6,6 @@ import { Input } from '@modl-gg/shared-web/components/ui/input'; import { Label } from '@modl-gg/shared-web/components/ui/label'; import { Textarea } from '@modl-gg/shared-web/components/ui/textarea'; import { Switch } from '@modl-gg/shared-web/components/ui/switch'; -import { Separator } from '@modl-gg/shared-web/components/ui/separator'; import { Card, CardContent, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; import { Badge } from '@modl-gg/shared-web/components/ui/badge'; import { Popover, PopoverContent, PopoverTrigger } from '@modl-gg/shared-web/components/ui/popover'; @@ -97,11 +96,18 @@ const EmbedTemplateEditor: React.FC = ({ const hexToRgb = (hex: string) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : null; + if (!result) { + return null; + } + const [, red, green, blue] = result; + if (red === undefined || green === undefined || blue === undefined) { + return null; + } + return { + r: parseInt(red, 16), + g: parseInt(green, 16), + b: parseInt(blue, 16) + }; }; const updateTemplate = (updates: Partial) => { @@ -114,8 +120,12 @@ const EmbedTemplateEditor: React.FC = ({ }; const updateField = (index: number, updates: Partial) => { + const existingField = template.fields[index]; + if (!existingField) { + return; + } const newFields = [...template.fields]; - newFields[index] = { ...newFields[index], ...updates }; + newFields[index] = { ...existingField, ...updates }; updateTemplate({ fields: newFields }); }; @@ -236,14 +246,19 @@ const EmbedTemplateEditor: React.FC = ({ const fields = template.fields; const result = []; let i = 0; - + 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) { + i += 1; + continue; + } + const nextField = fields[i + 1]; + const secondNextField = fields[i + 2]; + + if (currentField.inline && nextField?.inline && secondNextField?.inline) { result.push( -
+
{replaceVariablesForPreview(currentField.name)} @@ -254,48 +269,46 @@ const EmbedTemplateEditor: React.FC = ({
- {replaceVariablesForPreview(fields[i + 1].name)} + {replaceVariablesForPreview(nextField.name)}
- {replaceVariablesForPreview(fields[i + 1].value)} + {replaceVariablesForPreview(nextField.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 - result.push( -
- {replaceVariablesForPreview(currentField.name)} + {replaceVariablesForPreview(secondNextField.name)}
- {replaceVariablesForPreview(currentField.value)} + {replaceVariablesForPreview(secondNextField.value)}
+
+ ); + i += 3; + } else if (currentField.inline && nextField?.inline) { + result.push( +
- {replaceVariablesForPreview(fields[i + 1].name)} + {replaceVariablesForPreview(currentField.name)}
- {replaceVariablesForPreview(fields[i + 1].value)} + {replaceVariablesForPreview(currentField.value)}
- {replaceVariablesForPreview(fields[i + 2].name)} + {replaceVariablesForPreview(nextField.name)}
- {replaceVariablesForPreview(fields[i + 2].value)} + {replaceVariablesForPreview(nextField.value)}
); - i += 3; // Skip next two fields + i += 2; } else { - // Single field (either non-inline or single inline) result.push(
@@ -309,7 +322,7 @@ const EmbedTemplateEditor: React.FC = ({ i += 1; } } - + return result; })()}
diff --git a/client/src/components/settings/GeneralSettings.tsx b/client/src/components/settings/GeneralSettings.tsx index f0d0c5d..d4b8c1c 100644 --- a/client/src/components/settings/GeneralSettings.tsx +++ b/client/src/components/settings/GeneralSettings.tsx @@ -1,17 +1,22 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { CreditCard, SettingsIcon, Globe, Key, Upload, Eye, EyeOff, Check, Copy, RefreshCw, Trash2, Plus, ChevronDown, ChevronRight, HardDrive, MessageCircle, Database } from 'lucide-react'; +import { CreditCard, SettingsIcon, Globe, Key, Upload, Eye, EyeOff, Check, Copy, RefreshCw, Trash2, Plus, ChevronDown, ChevronRight, HardDrive, MessageCircle, Database, Info } from 'lucide-react'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { Input } from '@modl-gg/shared-web/components/ui/input'; import { Label } from '@modl-gg/shared-web/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@modl-gg/shared-web/components/ui/select'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@modl-gg/shared-web/components/ui/tooltip'; import { Separator } from '@modl-gg/shared-web/components/ui/separator'; +import { SUPPORTED_LANGUAGES } from '@/lib/languages'; 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'; import DomainSettings from './DomainSettings'; import UsageSettings from './UsageSettings'; import WebhookSettings from './WebhookSettings'; +import type { WebhookSettings as WebhookSettingsData } from './WebhookSettings'; import MigrationTool from './MigrationTool'; import { queryClient } from '@/lib/queryClient'; import { toast } from '@modl-gg/shared-web/hooks/use-toast'; @@ -20,8 +25,8 @@ interface GeneralSettingsProps { // Server Configuration serverDisplayName: string; setServerDisplayName: (value: string) => void; - discordWebhookUrl: string; - setDiscordWebhookUrl: (value: string) => void; + defaultLanguage: string; + setDefaultLanguage: (value: string) => void; // Server Icons homepageIconUrl: string; @@ -47,16 +52,14 @@ interface GeneralSettingsProps { maskApiKey: (key: string) => string; // Billing and Usage Data - usageData?: any; getBillingSummary: () => string; - getUsageSummary: () => string; getServerConfigSummary: () => string; getDomainSummary: () => string; // Webhook Settings - webhookSettings?: any; + webhookSettings?: WebhookSettingsData; getWebhookSummary: () => string; - handleWebhookSave: (settings: any) => Promise; + handleWebhookSave: (settings: WebhookSettingsData) => Promise; savingWebhookSettings?: boolean; // Optional prop to show only a specific section @@ -67,8 +70,8 @@ interface GeneralSettingsProps { const GeneralSettings = ({ serverDisplayName, setServerDisplayName, - discordWebhookUrl, - setDiscordWebhookUrl, + defaultLanguage, + setDefaultLanguage, homepageIconUrl, panelIconUrl, uploadingHomepageIcon, @@ -88,9 +91,7 @@ const GeneralSettings = ({ revealApiKey, copyApiKey, maskApiKey, - usageData, getBillingSummary, - getUsageSummary, getServerConfigSummary, getDomainSummary, webhookSettings, @@ -150,6 +151,34 @@ const GeneralSettings = ({ +
+
+ + + + + + + {t('settings.general.defaultLanguageTooltip')} + + +
+ +
+ + + {/* Server Icons */}

{t('settings.general.serverIcons')}

@@ -261,10 +290,22 @@ const GeneralSettings = ({ {showApiKey ? (fullApiKey || apiKey) : maskApiKey(apiKey)} - -
@@ -272,14 +313,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..402a3c3 100644 --- a/client/src/components/settings/HomepageCardSettings.tsx +++ b/client/src/components/settings/HomepageCardSettings.tsx @@ -9,51 +9,55 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import { queryClient } from '@/lib/queryClient'; import { apiFetch } from '@/lib/api'; +import { safeExternalHref } from '@/lib/utils'; 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 { DndProvider, useDrag, useDrop, type DropTargetMonitor } from 'react-dnd'; +import type { Identifier } from 'dnd-core'; 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 +96,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 +109,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'; @@ -131,7 +137,7 @@ const DraggableCardItem: React.FC = ({ const { t } = useTranslation(); const ref = useRef(null); - const [{ handlerId }, drop] = useDrop({ + const [{ handlerId }, drop] = useDrop({ accept: ITEM_TYPE_HOMEPAGE_CARD, collect(monitor) { return { handlerId: monitor.getHandlerId() }; @@ -337,7 +343,9 @@ const HomepageCardSettings: React.FC = () => { setDisplayedCards((prev) => { const updated = [...prev]; const [dragged] = updated.splice(dragIndex, 1); - updated.splice(hoverIndex, 0, dragged); + if (dragged) { + updated.splice(hoverIndex, 0, dragged); + } return updated; }); }, []); @@ -362,6 +370,14 @@ const HomepageCardSettings: React.FC = () => { }); }; + const rejectInvalidActionUrl = (): boolean => { + if (formData.actionType === 'url' && !safeExternalHref(formData.actionUrl)) { + toast({ title: t('toast.error'), description: t('settings.homepage.urlInvalid'), variant: 'destructive' }); + return true; + } + return false; + }; + const handleCreateCard = () => { if (!formData.title.trim() || !formData.description.trim()) { toast({ title: t('toast.error'), description: t('settings.homepage.titleDescRequired'), variant: 'destructive' }); @@ -373,6 +389,8 @@ const HomepageCardSettings: React.FC = () => { return; } + if (rejectInvalidActionUrl()) return; + if (formData.actionType === 'category_dropdown' && !formData.categoryId) { toast({ title: t('toast.error'), description: t('settings.homepage.categoryRequired'), variant: 'destructive' }); return; @@ -389,6 +407,8 @@ const HomepageCardSettings: React.FC = () => { return; } + if (rejectInvalidActionUrl()) return; + updateCardMutation.mutate({ id: editingCard.id, ...formData }); }; @@ -423,7 +443,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..ea14939 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, @@ -23,6 +23,7 @@ import { import { Input } from '@modl-gg/shared-web/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@modl-gg/shared-web/components/ui/select'; import { useAuth } from '@/hooks/use-auth'; +import { errorMessageOr } from '@/utils/errors'; import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import { Loader2 } from 'lucide-react'; import { useRoles } from '@/hooks/use-data'; @@ -104,10 +105,10 @@ const InviteStaffModal: React.FC = ({ isOpen, onClose, on // Trigger refresh and close modal onInviteSent(); onClose(); - } catch (error: any) { + } catch (error) { toast({ title: t('toast.error'), - description: error.message || t('settings.staff.sendInvitationFailed'), + description: errorMessageOr(error, t('settings.staff.sendInvitationFailed')), variant: 'destructive', }); } finally { @@ -136,9 +137,6 @@ const InviteStaffModal: React.FC = ({ isOpen, onClose, on -

- {t('settings.staff.iCloudNotice')} -

)} /> diff --git a/client/src/components/settings/KnowledgebaseSettings.tsx b/client/src/components/settings/KnowledgebaseSettings.tsx index c17d0e5..48b47f8 100644 --- a/client/src/components/settings/KnowledgebaseSettings.tsx +++ b/client/src/components/settings/KnowledgebaseSettings.tsx @@ -7,20 +7,34 @@ import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@modl-gg/shared-web/components/ui/alert-dialog'; import { queryClient } from '@/lib/queryClient'; import { apiFetch } from '@/lib/api'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useQuery, useMutation, type UseMutationResult } from '@tanstack/react-query'; import { Plus, Edit, Trash2, GripVertical } from 'lucide-react'; -import { DndProvider, useDrag, useDrop, DropTargetMonitor } from 'react-dnd'; +import { DndProvider, useDrag, useDrop, type DropTargetMonitor } from 'react-dnd'; +import type { Identifier } from 'dnd-core'; import { HTML5Backend } from 'react-dnd-html5-backend'; import ArticleListItem from './ArticleListItem'; import MarkdownEditor from '@modl-gg/shared-web/components/ui/MarkdownEditor'; -import { KnowledgebaseCategory, KnowledgebaseArticle } from '@modl-gg/shared-web/types'; +import { type KnowledgebaseCategory, type KnowledgebaseArticle, type KnowledgebaseArticleStub } from '@modl-gg/shared-web/types'; -const normalizeArticle = (article: any): KnowledgebaseArticle => ({ +interface ArticleVisibilityInput { + isVisible?: boolean; + visible?: boolean; +} + +type RawKnowledgebaseArticle = Omit & ArticleVisibilityInput; + +type RawKnowledgebaseArticleStub = Omit & ArticleVisibilityInput; + +type RawKnowledgebaseCategory = Omit & { + articles?: RawKnowledgebaseArticleStub[]; +}; + +const normalizeArticle = (article: T): T & { isVisible: boolean } => ({ ...article, isVisible: article?.isVisible ?? article?.visible ?? true, }); -const normalizeCategory = (category: any): KnowledgebaseCategory => ({ +const normalizeCategory = (category: RawKnowledgebaseCategory): KnowledgebaseCategory => ({ ...category, articles: Array.isArray(category?.articles) ? category.articles.map(normalizeArticle) @@ -37,13 +51,19 @@ 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', ARTICLE: 'article', }; +interface CategoryDragItem { + id: string; + originalIndex: number; + type: typeof ItemTypes.CATEGORY; +} + interface CategoryItemProps { category: KnowledgebaseCategory; index: number; @@ -54,16 +74,11 @@ interface CategoryItemProps { editingCategory: KnowledgebaseCategory | null; handleUpdateCategory: () => void; setEditingCategory: React.Dispatch>; - updateCategoryMutation: any; - deleteCategoryMutation: any; - editingArticle: KnowledgebaseArticle | null; - setEditingArticle: React.Dispatch>; - handleUpdateArticle: () => void; + updateCategoryMutation: UseMutationResult; + deleteCategoryMutation: UseMutationResult; handleDeleteArticle: (categoryId: string, articleId: string, title: string) => void; - updateArticleMutation: any; // This should be specific to article update - deleteArticleMutation: any; handleDropCategory: () => void; - reorderArticlesMutation: any; // Add mutation for reordering articles + reorderArticlesMutation: UseMutationResult; fetchArticleForEditing: (categoryId: string, articleId: string) => Promise; } @@ -79,23 +94,20 @@ const CategoryItem: React.FC = ({ setEditingCategory, updateCategoryMutation, deleteCategoryMutation, - editingArticle, - setEditingArticle, - handleUpdateArticle, // This is the global one for opening edit modal handleDeleteArticle, // This is the global one - updateArticleMutation: uam, // This is the global update mutation - deleteArticleMutation: dam, // This is the global delete mutation handleDropCategory, reorderArticlesMutation, fetchArticleForEditing }) => { const { t } = useTranslation(); const ref = React.useRef(null); - const [displayedCategoryArticles, setDisplayedCategoryArticles] = useState([]); + const [displayedCategoryArticles, setDisplayedCategoryArticles] = useState([]); 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]); @@ -104,7 +116,9 @@ const CategoryItem: React.FC = ({ setDisplayedCategoryArticles((prevArticles) => { const updatedArticles = [...prevArticles]; const [draggedItem] = updatedArticles.splice(dragIndex, 1); - updatedArticles.splice(hoverIndex, 0, draggedItem); + if (draggedItem) { + updatedArticles.splice(hoverIndex, 0, draggedItem); + } return updatedArticles; }); }, [category.id]); @@ -115,14 +129,14 @@ const CategoryItem: React.FC = ({ reorderArticlesMutation.mutate({ categoryId: category.id, orderedArticleIds }); }, [category.id, displayedCategoryArticles, reorderArticlesMutation]); - const [{ handlerId }, drop] = useDrop({ + const [{ handlerId }, drop] = useDrop({ accept: ItemTypes.CATEGORY, collect(monitor) { return { handlerId: monitor.getHandlerId(), }; }, - hover(item: CategoryDragItem, monitor: DropTargetMonitor) { + hover(item: CategoryDragItem, _monitor: DropTargetMonitor) { if (!ref.current) return; const dragIndex = item.originalIndex; const hoverIndex = index; @@ -136,7 +150,7 @@ const CategoryItem: React.FC = ({ type: ItemTypes.CATEGORY, item: () => ({ id: category.id, originalIndex: index, type: ItemTypes.CATEGORY }), collect: (monitor) => ({ isDragging: monitor.isDragging() }), - end: (item, monitor) => { + end: (_item, monitor) => { // If the item was dropped on a compatible target (which it will be, even if it's its own spot after moving) // and the drop was not cancelled, then we persist the order. if (monitor.didDrop()) { @@ -188,9 +202,9 @@ const CategoryItem: React.FC = ({ ) : ( - + )} - +
@@ -339,7 +353,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]); @@ -378,7 +394,9 @@ const KnowledgebaseSettings: React.FC = () => { setDisplayedCategories((prevCategories) => { const updatedCategories = [...prevCategories]; const [draggedItem] = updatedCategories.splice(dragIndex, 1); - updatedCategories.splice(hoverIndex, 0, draggedItem); + if (draggedItem) { + updatedCategories.splice(hoverIndex, 0, draggedItem); + } return updatedCategories; }); }, []); @@ -514,7 +532,7 @@ const KnowledgebaseSettings: React.FC = () => { if (!response.ok) { throw new Error('Failed to fetch article details'); } - const articleData = await response.json(); + const articleData: RawKnowledgebaseArticle = await response.json(); setEditingArticle(normalizeArticle(articleData)); // Scroll to the article editing section with smooth animation @@ -610,12 +628,7 @@ const KnowledgebaseSettings: React.FC = () => { updateCategoryMutation={updateCategoryMutation} deleteCategoryMutation={deleteCategoryMutation} // Pass article related props - editingArticle={editingArticle} - setEditingArticle={setEditingArticle} - handleUpdateArticle={handleUpdateArticle} handleDeleteArticle={handleDeleteArticleClick} // Global delete - updateArticleMutation={updateArticleMutation} // Global update for articles (modal) - deleteArticleMutation={deleteArticleMutation} // Global delete for articles handleDropCategory={handleDropCategory} reorderArticlesMutation={reorderArticlesMutation} // Pass down the reorder mutation fetchArticleForEditing={fetchArticleForEditing} diff --git a/client/src/components/settings/MigrationTool.tsx b/client/src/components/settings/MigrationTool.tsx index 470b8c0..31ed3f3 100644 --- a/client/src/components/settings/MigrationTool.tsx +++ b/client/src/components/settings/MigrationTool.tsx @@ -12,11 +12,13 @@ const MIGRATION_TYPES = [ { value: 'LiteBans', label: 'LiteBans (Spigot/Velocity/BungeeCord)' } ]; +type MigrationRecord = NonNullable['data']>['currentMigration']>; + const MigrationTool: React.FC = () => { const { t } = useTranslation(); const [selectedType, setSelectedType] = useState(''); const [showCompletedAlert, setShowCompletedAlert] = useState(false); - const [lastCompletedMigration, setLastCompletedMigration] = useState(null); + const [lastCompletedMigration, setLastCompletedMigration] = useState(null); const { data: migrationStatus, isLoading: statusLoading } = useMigrationStatus(); const startMigration = useStartMigration(); @@ -45,7 +47,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 +78,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 +105,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); + const getProgressPercentage = (status: string, progress?: MigrationRecord['progress']) => { + // 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 +148,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 +171,7 @@ const MigrationTool: React.FC = () => { {/* Active Migration Progress */} {isActive && currentMigration && ( - +
@@ -172,7 +179,7 @@ const MigrationTool: React.FC = () => {

{getStatusText(currentMigration.status)}

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

@@ -209,8 +216,8 @@ const MigrationTool: React.FC = () => { {t('settings.migration.processed', { count: currentMigration.progress.recordsProcessed })} {currentMigration.progress.totalRecords && ` / ${currentMigration.progress.totalRecords}`} - {currentMigration.progress.recordsSkipped > 0 && ( - + {(currentMigration.progress.recordsSkipped ?? 0) > 0 && ( + {t('settings.migration.skipped', { count: currentMigration.progress.recordsSkipped })} )} @@ -229,7 +236,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 +245,9 @@ const MigrationTool: React.FC = () => { + ) : isCancelled ? ( - + ) : undefined} > {isSuccess ? ( @@ -248,7 +256,7 @@ const MigrationTool: React.FC = () => { {lastCompletedMigration.progress && ( {t('settings.migration.processedRecords', { count: lastCompletedMigration.progress.recordsProcessed })} - {lastCompletedMigration.progress.recordsSkipped > 0 && + {(lastCompletedMigration.progress.recordsSkipped ?? 0) > 0 && t('settings.migration.skippedSuffix', { count: lastCompletedMigration.progress.recordsSkipped })}. )} @@ -265,7 +273,7 @@ const MigrationTool: React.FC = () => { {onCooldown && !isActive && ( } + icon={} > {t('settings.migration.cooldownActive', { time: formatCooldownTime(cooldownRemainingMs) })} @@ -313,53 +321,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..f60ff60 100644 --- a/client/src/components/settings/PasskeySettings.tsx +++ b/client/src/components/settings/PasskeySettings.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Fingerprint, Plus, Pencil, Trash2, Loader2 } from 'lucide-react'; -import { startRegistration } from '@simplewebauthn/browser'; +import { startRegistration, type PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'; +import { isWebAuthnCancellation, unwrapPublicKeyOptions, type MaybePublicKeyWrapped } from '@/utils/webauthn'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { Input } from '@modl-gg/shared-web/components/ui/input'; import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; @@ -64,7 +65,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 @@ -86,15 +88,15 @@ const PasskeySettings = () => { return; } - const { challengeId, options } = await optionsRes.json(); - const attResp = await startRegistration({ optionsJSON: options?.publicKey ?? options }); + const { challengeId, options }: { challengeId: string; options: MaybePublicKeyWrapped } = await optionsRes.json(); + const attResp = await startRegistration({ optionsJSON: unwrapPublicKeyOptions(options) }); setPendingChallengeId(challengeId); setPendingResponse(JSON.stringify(attResp)); setCredentialName(''); setNameDialogOpen(true); - } catch (e: any) { - if (e.name !== 'NotAllowedError') { + } catch (e) { + if (!isWebAuthnCancellation(e)) { toast({ title: t('toast.error'), description: t('settings.passkey.registrationCancelledOrFailed'), variant: 'destructive' }); } } finally { @@ -290,6 +292,9 @@ const PasskeySettings = () => { {t('settings.passkey.renamePasskey')} + + {t('settings.passkey.renamePasskeyDesc')} + { {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..a79eeee 100644 --- a/client/src/components/settings/PunishmentSettings.tsx +++ b/client/src/components/settings/PunishmentSettings.tsx @@ -115,13 +115,17 @@ const PunishmentSettings = ({ min={1} max={20} step={1} - onValueChange={values => setStatusThresholds(prev => ({ - ...prev, - gameplay: { - ...prev.gameplay, - medium: values[0] - } - }))} + onValueChange={values => { + const medium = values[0] ?? statusThresholds.gameplay.medium; + setStatusThresholds(prev => ({ + ...prev, + gameplay: { + ...prev.gameplay, + medium, + habitual: Math.max(prev.gameplay.habitual, medium + 1) + } + })); + }} />
@@ -136,13 +140,16 @@ const PunishmentSettings = ({ min={statusThresholds.gameplay.medium + 1} max={30} step={1} - onValueChange={values => setStatusThresholds(prev => ({ - ...prev, - gameplay: { - ...prev.gameplay, - habitual: values[0] - } - }))} + onValueChange={values => { + const habitual = values[0] ?? statusThresholds.gameplay.habitual; + setStatusThresholds(prev => ({ + ...prev, + gameplay: { + ...prev.gameplay, + habitual + } + })); + }} />
@@ -157,13 +164,16 @@ const PunishmentSettings = ({ min={1} max={60} step={1} - onValueChange={values => setStatusThresholds(prev => ({ - ...prev, - gameplay: { - ...prev.gameplay, - pointExpiryMonths: values[0] - } - }))} + onValueChange={values => { + const pointExpiryMonths = values[0] ?? statusThresholds.gameplay.pointExpiryMonths; + setStatusThresholds(prev => ({ + ...prev, + gameplay: { + ...prev.gameplay, + pointExpiryMonths + } + })); + }} />
@@ -186,13 +196,17 @@ const PunishmentSettings = ({ min={1} max={20} step={1} - onValueChange={values => setStatusThresholds(prev => ({ - ...prev, - social: { - ...prev.social, - medium: values[0] - } - }))} + onValueChange={values => { + const medium = values[0] ?? statusThresholds.social.medium; + setStatusThresholds(prev => ({ + ...prev, + social: { + ...prev.social, + medium, + habitual: Math.max(prev.social.habitual, medium + 1) + } + })); + }} /> @@ -207,13 +221,16 @@ const PunishmentSettings = ({ min={statusThresholds.social.medium + 1} max={30} step={1} - onValueChange={values => setStatusThresholds(prev => ({ - ...prev, - social: { - ...prev.social, - habitual: values[0] - } - }))} + onValueChange={values => { + const habitual = values[0] ?? statusThresholds.social.habitual; + setStatusThresholds(prev => ({ + ...prev, + social: { + ...prev.social, + habitual + } + })); + }} /> @@ -228,13 +245,16 @@ const PunishmentSettings = ({ min={1} max={60} step={1} - onValueChange={values => setStatusThresholds(prev => ({ - ...prev, - social: { - ...prev.social, - pointExpiryMonths: values[0] - } - }))} + onValueChange={values => { + const pointExpiryMonths = values[0] ?? statusThresholds.social.pointExpiryMonths; + setStatusThresholds(prev => ({ + ...prev, + social: { + ...prev.social, + pointExpiryMonths + } + })); + }} /> diff --git a/client/src/components/settings/StaffManagementPanel.tsx b/client/src/components/settings/StaffManagementPanel.tsx index 1776d01..b668985 100644 --- a/client/src/components/settings/StaffManagementPanel.tsx +++ b/client/src/components/settings/StaffManagementPanel.tsx @@ -1,14 +1,13 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; -import { useAuth } from '@/hooks/use-auth'; import { usePermissions } from '@/hooks/use-permissions'; 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 ChangeRoleModal from './ChangeRoleModal'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@modl-gg/shared-web/components/ui/table'; -import { useStaff, useRoles } from '@/hooks/use-data'; +import { useStaff } from '@/hooks/use-data'; import { Skeleton } from '@modl-gg/shared-web/components/ui/skeleton'; import { MoreHorizontal, RefreshCw, User } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@modl-gg/shared-web/components/ui/dropdown-menu'; @@ -29,18 +28,9 @@ interface StaffMember { assignedMinecraftUsername?: string; } -// Role interface to match the one from StaffRolesCard -interface Role { - id: string; - name: string; - order?: number; -} - const StaffManagementPanel = () => { const { t } = useTranslation(); - const { data: staff, isLoading, error, refetch: refetchStaff, isRefetching } = useStaff(); - const { data: rolesData } = useRoles(); - const { user: currentUser } = useAuth(); + const { data: staff, isLoading, error, refetch: refetchStaff } = useStaff(); const { hasPermission, canModifyUserRole, canRemoveStaffUser, canAssignStaffMinecraftPlayer } = usePermissions(); // Helper function to check if there are any available actions for a staff member @@ -120,10 +110,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 +139,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..a8244ae 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' }, @@ -177,14 +178,14 @@ const DraggableRoleCard: React.FC = ({ // User can drag roles that have higher order number (lower authority) and not super admin const canDragRole = role.name !== 'Super Admin' && currentUserOrder < roleOrder; - const [{ isDragging }, drag, preview] = useDrag(() => ({ + const [{ isDragging }, drag] = useDrag(() => ({ type: 'role', item: () => { onDragStart(); return { index, role, originalIndex: index }; }, canDrag: canDragRole, - end: (item, monitor) => { + end: (_item, monitor) => { const didDrop = monitor.didDrop(); onDragEnd(didDrop); }, @@ -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')} > @@ -333,7 +336,7 @@ export default function StaffRolesCard() { const [originalRoles, setOriginalRoles] = useState([]); const [pendingReorder, setPendingReorder] = useState(null); const [showReorderConfirm, setShowReorderConfirm] = useState(false); - const [isDragInProgress, setIsDragInProgress] = useState(false); + const [, setIsDragInProgress] = useState(false); const { toast } = useToast(); // API hooks @@ -355,6 +358,7 @@ export default function StaffRolesCard() { const roles = rolesData?.roles || []; const permissions = permissionsData?.permissions || []; + const permissionCatalog = permissions.length > 0 ? permissions : DEFAULT_PERMISSIONS; // If no roles are loaded from the database, use default roles as fallback const effectiveRoles = roles.length > 0 ? roles : DEFAULT_ROLES; @@ -367,10 +371,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 +385,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); @@ -403,7 +409,8 @@ export default function StaffRolesCard() { const moveRole = (dragIndex: number, hoverIndex: number) => { const newRoles = [...localRoles]; const draggedRole = newRoles[dragIndex]; - + if (!draggedRole) return; + // Remove the dragged role and insert at new position newRoles.splice(dragIndex, 1); newRoles.splice(hoverIndex, 0, draggedRole); @@ -413,7 +420,7 @@ export default function StaffRolesCard() { }; // Handle role reordering when drag ends (actual commit) - const commitRoleReorder = (originalIndex: number, targetIndex: number) => { + const commitRoleReorder = (_originalIndex: number, _targetIndex: number) => { // The localRoles state already reflects the current visual state from hover operations // We just need to set pending reorder and show confirmation setPendingReorder([...localRoles]); @@ -428,11 +435,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: { @@ -620,9 +657,8 @@ export default function StaffRolesCard() { const togglePermission = (permissionId: string) => { if (!canGrantPermission(permissionId)) return; - const allPerms = permissions.length > 0 ? permissions : DEFAULT_PERMISSIONS; const isCurrentlyEnabled = roleFormData.permissions.includes(permissionId); - const childPermissions = allPerms.filter((p: Permission) => p.parentId === permissionId).map((p: Permission) => p.id); + const childPermissions = permissionCatalog.filter((p: Permission) => p.parentId === permissionId).map((p: Permission) => p.id); if (isCurrentlyEnabled) { // Toggling OFF: remove only the parent, children become individually manageable @@ -640,7 +676,7 @@ export default function StaffRolesCard() { }; const getPermissionsByCategory = (category: string) => { - return permissions.filter((p: Permission) => p.category === category); + return permissionCatalog.filter((p: Permission) => p.category === category); }; const hasPermission = (role: StaffRole, permissionId: string) => { @@ -885,29 +921,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..9ba5e05 100644 --- a/client/src/components/settings/TicketSettings.tsx +++ b/client/src/components/settings/TicketSettings.tsx @@ -1,26 +1,26 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { MessageCircle, Tag, Plus, X, ChevronDown, ChevronRight, Layers, Shield, Edit3, Trash2, GripVertical, Save, Crown } from 'lucide-react'; +import { MessageCircle, Plus, X, ChevronDown, ChevronRight, Layers, Edit3, Trash2, GripVertical, Save } from 'lucide-react'; import { Button } from '@modl-gg/shared-web/components/ui/button'; import { Input } from '@modl-gg/shared-web/components/ui/input'; import { Textarea } from '@modl-gg/shared-web/components/ui/textarea'; import { Label } from '@modl-gg/shared-web/components/ui/label'; import { Badge } from '@modl-gg/shared-web/components/ui/badge'; import { Switch } from '@modl-gg/shared-web/components/ui/switch'; -import { Slider } from '@modl-gg/shared-web/components/ui/slider'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@modl-gg/shared-web/components/ui/select'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@modl-gg/shared-web/components/ui/collapsible'; import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { Card, CardContent, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; import { Separator } from '@modl-gg/shared-web/components/ui/separator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@modl-gg/shared-web/components/ui/tabs'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } 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 { QuickResponseAction, QuickResponseCategory, QuickResponsesConfiguration, defaultQuickResponsesConfig } from '@/types/quickResponses'; -import { TicketFormField, TicketFormSection, TicketFormSettings, TicketFormsConfiguration } from '@/types/forms'; +import { type QuickResponseAction, type QuickResponseCategory, type QuickResponsesConfiguration, defaultQuickResponsesConfig } from '@/types/quickResponses'; +import { type TicketFormField, type TicketFormSection, type TicketFormsConfiguration } from '@/types/forms'; import { useBillingStatus } from '@/hooks/use-data'; import { useAuth } from '@/hooks/use-auth'; import { hasPremiumAccess } from '@/lib/backend-enums'; +import type { PunishmentType } from '@modl-gg/proto/modl/v1/settings_pb.ts'; // Label type definition interface Label { @@ -30,10 +30,23 @@ interface Label { description?: string; } +export interface AIPunishmentConfig { + id: string; + name: string; + aiDescription: string; + enabled: boolean; +} + +export interface AIModerationSettings { + enableAIReview: boolean; + enableAutomatedActions: boolean; + aiPunishmentConfigs: Record; +} + interface TicketSettingsProps { // Quick Responses State quickResponsesState: QuickResponsesConfiguration; - setQuickResponsesState: (value: QuickResponsesConfiguration) => void; + setQuickResponsesState: (value: QuickResponsesConfiguration | ((prev: QuickResponsesConfiguration) => QuickResponsesConfiguration)) => void; // Label Management State (new unified system) labels: Label[]; @@ -60,22 +73,13 @@ interface TicketSettingsProps { setSelectedTicketFormType: (value: 'bug' | 'support' | 'application') => void; // AI Moderation State - aiModerationSettings: any; - setAiModerationSettings: (value: any) => void; - punishmentTypesState: any[]; - - // Optional callbacks that may be passed from parent - onEditSection?: (section: TicketFormSection) => void; - onDeleteSection?: (sectionId: string) => void; - onEditField?: (field: TicketFormField) => void; - onDeleteField?: (fieldId: string) => void; - onAddField?: () => void; - moveField?: (dragIndex: number, hoverIndex: number, sectionId: string) => void; + aiModerationSettings: AIModerationSettings; + setAiModerationSettings: (value: AIModerationSettings | ((prev: AIModerationSettings) => AIModerationSettings)) => void; + punishmentTypesState: PunishmentType[]; + moveFieldBetweenSections?: (fieldId: string, fromSectionId: string, toSectionId: string, targetIndex?: number) => void; - // Optional prop to show only a specific section - // 'quick-responses' | 'label-management' | 'ticket-forms' | 'ai-moderation' | undefined (show all) - visibleSection?: string; + visibleSection: 'quick-responses' | 'label-management' | 'ticket-forms' | 'ai-moderation'; } // Default label colors for the color picker @@ -94,6 +98,26 @@ const DEFAULT_LABEL_COLORS = [ '#ffffff', // White ]; +const TICKET_TYPE_LABEL_KEYS: Record = { + chat_report: 'tickets.chatReport', + player_report: 'tickets.playerReport', + bug: 'tickets.bugReport', + appeal: 'tickets.banAppeal', + support: 'tickets.support', + application: 'tickets.staffApplication', +}; + +function formatTicketTypeLabel(type: string, t: (key: string) => string): string { + const key = TICKET_TYPE_LABEL_KEYS[type]; + if (key) { + return t(key); + } + return type + .split('_') + .map((word) => (word ? word.charAt(0).toUpperCase() + word.slice(1) : word)) + .join(' '); +} + // Label Management Table Component interface LabelManagementTableProps { labels: Label[]; @@ -353,12 +377,6 @@ const TicketSettings = ({ setPlayerReportTags, appealTags, setAppealTags, - newBugTag, - setNewBugTag, - newPlayerTag, - setNewPlayerTag, - newAppealTag, - setNewAppealTag, ticketForms, setTicketForms, selectedTicketFormType, @@ -366,13 +384,6 @@ const TicketSettings = ({ aiModerationSettings, setAiModerationSettings, punishmentTypesState, - // Optional props with defaults - onEditSection, - onDeleteSection, - onEditField, - onDeleteField, - onAddField, - moveField, moveFieldBetweenSections, visibleSection }: TicketSettingsProps) => { @@ -393,12 +404,6 @@ const TicketSettings = ({ }); }; - // Collapsible state - const [isQuickResponsesExpanded, setIsQuickResponsesExpanded] = useState(false); - const [isTagManagementExpanded, setIsTagManagementExpanded] = useState(false); - const [isTicketFormsExpanded, setIsTicketFormsExpanded] = useState(false); - const [isAIModerationExpanded, setIsAIModerationExpanded] = useState(false); - // Quick Response editing states const [editingAction, setEditingAction] = useState(null); const [editingCategory, setEditingCategory] = useState(null); @@ -415,7 +420,7 @@ const TicketSettings = ({ const [newTicketFormFieldRequired, setNewTicketFormFieldRequired] = useState(false); const [newTicketFormFieldOptions, setNewTicketFormFieldOptions] = useState([]); const [newTicketFormFieldSectionId, setNewTicketFormFieldSectionId] = useState(''); - const [newTicketFormFieldGoToSection, setNewTicketFormFieldGoToSection] = useState(''); + const [, setNewTicketFormFieldGoToSection] = useState(''); const [newTicketFormFieldOptionSectionMapping, setNewTicketFormFieldOptionSectionMapping] = useState>({}); const [newTicketFormOption, setNewTicketFormOption] = useState(''); const [isOptionNavigationExpanded, setIsOptionNavigationExpanded] = useState(false); @@ -429,9 +434,7 @@ const TicketSettings = ({ // AI Punishment Types states const [isAddAIPunishmentDialogOpen, setIsAddAIPunishmentDialogOpen] = useState(false); - const [selectedAIPunishmentType, setSelectedAIPunishmentType] = useState(null); - const [selectedPunishmentTypeId, setSelectedPunishmentTypeId] = useState(null); - const [newAIPunishmentDescription, setNewAIPunishmentDescription] = useState(''); + const [selectedAIPunishmentType, setSelectedAIPunishmentType] = useState(null); // Quick Response deletion confirmation state const [quickResponseDeleteDialogOpen, setQuickResponseDeleteDialogOpen] = useState(false); @@ -445,11 +448,6 @@ const TicketSettings = ({ const [tagDeleteDialogOpen, setTagDeleteDialogOpen] = useState(false); const [tagToDelete, setTagToDelete] = useState<{type: 'bug' | 'player' | 'appeal'; index: number; name: string} | null>(null); - const handleTagDeleteClick = (type: 'bug' | 'player' | 'appeal', index: number, name: string) => { - setTagToDelete({ type, index, name }); - setTagDeleteDialogOpen(true); - }; - const confirmTagDelete = () => { if (tagToDelete) { if (tagToDelete.type === 'bug') { @@ -574,17 +572,6 @@ const TicketSettings = ({ })); }; - const addNewTicketFormFieldOption = () => { - if (newTicketFormOption.trim()) { - setNewTicketFormFieldOptions(prev => [...prev, newTicketFormOption.trim()]); - setNewTicketFormOption(''); - } - }; - - const removeTicketFormFieldOption = (index: number) => { - setNewTicketFormFieldOptions(prev => prev.filter((_, i) => i !== index)); - }; - // Section Management Functions const addTicketFormSection = () => { if (!newTicketFormSectionTitle.trim()) return; @@ -648,9 +635,12 @@ const TicketSettings = ({ setTicketForms(prev => { const sections = [...(prev[selectedTicketFormType]?.sections || [])]; const dragSection = sections[dragIndex]; + if (!dragSection) { + return prev; + } sections.splice(dragIndex, 1); sections.splice(hoverIndex, 0, dragSection); - + // Update order values const updatedSections = sections.map((section, index) => ({ ...section, @@ -678,9 +668,12 @@ const TicketSettings = ({ // Reorder within section const dragField = sectionFields[dragIndex]; + if (!dragField) { + return prev; + } sectionFields.splice(dragIndex, 1); sectionFields.splice(hoverIndex, 0, dragField); - + // Update order values for fields in this section const updatedSectionFields = sectionFields.map((field, index) => ({ ...field, @@ -702,7 +695,7 @@ const TicketSettings = ({ }, [selectedTicketFormType, setTicketForms]); // Create default implementations for optional callbacks - const defaultMoveFieldBetweenSections = React.useCallback((fieldId: string, fromSectionId: string, toSectionId: string, targetIndex?: number) => { + const defaultMoveFieldBetweenSections = React.useCallback((fieldId: string, _fromSectionId: string, toSectionId: string, targetIndex?: number) => { setTicketForms(prev => { const allFields = [...(prev[selectedTicketFormType]?.fields || [])]; @@ -754,9 +747,12 @@ const TicketSettings = ({ actions: (() => { const actions = [...category.actions]; const draggedAction = actions[dragIndex]; + if (!draggedAction) { + return category.actions; + } actions.splice(dragIndex, 1); actions.splice(hoverIndex, 0, draggedAction); - + // Update order values return actions.map((action, index) => ({ ...action, @@ -776,11 +772,6 @@ const TicketSettings = ({ } }, [quickResponsesState, setQuickResponsesState]); - // AI Moderation computed values - const availablePunishmentTypes = punishmentTypesState?.filter(pt => - pt.isCustomizable && pt.ordinal != null && (!aiModerationSettings.aiPunishmentConfigs?.[pt.ordinal] || !aiModerationSettings.aiPunishmentConfigs[pt.ordinal].enabled) - ) || []; - // Clear form when dialog opens for new field (not editing) useEffect(() => { if (isAddTicketFormFieldDialogOpen && !selectedTicketFormField) { @@ -796,12 +787,6 @@ const TicketSettings = ({ } }, [isAddTicketFormFieldDialogOpen, selectedTicketFormField]); - // Determine which sections to show - const showQuickResponses = !visibleSection || visibleSection === 'quick-responses'; - const showLabelManagement = !visibleSection || visibleSection === 'label-management'; - const showTicketForms = !visibleSection || visibleSection === 'ticket-forms'; - const showAIModeration = !visibleSection || visibleSection === 'ai-moderation'; - // Helper to render Quick Responses content const quickResponsesContent = ( @@ -817,7 +802,7 @@ const TicketSettings = ({
{category.name}

- {category.ticketTypes.join(', ')} - {t('settings.tickets.actionsCount', { count: category.actions.length })} + {category.ticketTypes.map((type) => formatTicketTypeLabel(type, t)).join(', ')} - {t('settings.tickets.actionsCount', { count: category.actions.length })}

@@ -922,116 +907,29 @@ const TicketSettings = ({ <> {/* Add AI Punishment Type Dialog */} {isAddAIPunishmentDialogOpen && ( - - - - {t('settings.tickets.enableAIPunishmentType')} - - {selectedPunishmentTypeId ? (() => { - const selectedType = punishmentTypesState.find(pt => pt.id === selectedPunishmentTypeId); - return selectedType ? t('settings.tickets.configureAIDescFor', { name: selectedType.name }) : t('settings.tickets.configureAIDescSelected'); - })() : t('settings.tickets.selectPunishmentTypeForAI')} - - - -
- {selectedPunishmentTypeId && (() => { - const selectedType = punishmentTypesState.find(t => t.id === selectedPunishmentTypeId); - return selectedType ? ( -
-
-
-
{selectedType.name}
-
- - {selectedType.category} - - - {t('settings.tickets.ordinal')}: {selectedType.ordinal} - -
-
-
-
- ) : null; - })()} - - {!selectedPunishmentTypeId && ( -
- - -
- )} - - {selectedPunishmentTypeId && ( -
- -