diff --git a/client/src/components/settings/AccountSettings.tsx b/client/src/components/settings/AccountSettings.tsx index ef2f4ff..c5f60a5 100644 --- a/client/src/components/settings/AccountSettings.tsx +++ b/client/src/components/settings/AccountSettings.tsx @@ -17,6 +17,8 @@ interface AccountSettingsProps { userRole?: string; language: string; setLanguage: (value: string) => void; + dateFormat: string; + setDateFormat: (value: string) => void; } const AccountSettings = ({ @@ -27,7 +29,9 @@ const AccountSettings = ({ minecraftUsername, userRole, language, - setLanguage + setLanguage, + dateFormat, + setDateFormat }: AccountSettingsProps) => { const { toast } = useToast(); const { logout } = useAuth(); @@ -120,6 +124,7 @@ const AccountSettings = ({ English Deutsch Español + Nederlands

@@ -127,6 +132,25 @@ const AccountSettings = ({

+ +
+ +
+ +

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

+
+
); diff --git a/client/src/components/windows/PlayerWindow.tsx b/client/src/components/windows/PlayerWindow.tsx index 6b88f3a..8ac07ce 100644 --- a/client/src/components/windows/PlayerWindow.tsx +++ b/client/src/components/windows/PlayerWindow.tsx @@ -1052,13 +1052,20 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind // Wait a bit for the tab to render, then scroll to the punishment setTimeout(() => { - const punishmentElement = document.querySelector(`[data-punishment-id="${punishmentIdParam}"]`); + const punishmentElement = document.querySelector(`[data-punishment-id="${punishmentIdParam}"]`) as HTMLElement; if (punishmentElement) { - punishmentElement.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); - + // Find the scrollable content container (overflow-y-auto parent) instead of using scrollIntoView + const scrollContainer = punishmentElement.closest('.overflow-y-auto'); + if (scrollContainer) { + const containerRect = scrollContainer.getBoundingClientRect(); + const elementRect = punishmentElement.getBoundingClientRect(); + const offsetTop = elementRect.top - containerRect.top + scrollContainer.scrollTop; + scrollContainer.scrollTo({ + top: offsetTop - containerRect.height / 2 + elementRect.height / 2, + behavior: 'smooth' + }); + } + // Add a highlight effect punishmentElement.classList.add('bg-blue-100', 'border-2', 'border-blue-400'); setTimeout(() => { @@ -1326,45 +1333,28 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind // Apply modifications in chronological order const sortedModifications = modifications.sort((a: any, b: any) => { - const dateA = a.issued ? new Date(a.issued) : new Date(0); - const dateB = b.issued ? new Date(b.issued) : new Date(0); - return dateA.getTime() - dateB.getTime(); + const dateA = a.date ? new Date(a.date).getTime() : 0; + const dateB = b.date ? new Date(b.date).getTime() : 0; + return dateA - dateB; }); - + for (const mod of sortedModifications) { if (mod.type === 'MANUAL_PARDON' || mod.type === 'APPEAL_ACCEPT') { - effectiveActive = false; } else if (mod.type === 'MANUAL_DURATION_CHANGE' || mod.type === 'APPEAL_DURATION_CHANGE') { + effectiveActive = false; + } else if (mod.type === 'MANUAL_DURATION_CHANGE' || mod.type === 'APPEAL_DURATION_CHANGE') { if (mod.effectiveDuration !== undefined) { effectiveDuration = mod.effectiveDuration; - - // For duration modifications, calculate expiry from the modification's issued time - const modificationTime = mod.issued; - - // Convert modificationTime to Date object if it's a string - let modDate; - if (modificationTime instanceof Date) { - modDate = modificationTime; - } else if (typeof modificationTime === 'string') { - modDate = new Date(modificationTime); - } else { - // Fallback to current date if modificationTime is invalid - console.warn('Invalid modification time, using current date as fallback:', modificationTime); - modDate = new Date(); - } - - // Validate the modDate - if (isNaN(modDate.getTime())) { - console.warn('Invalid modification date calculated, using current date as fallback:', modDate); - modDate = new Date(); - } - if (mod.effectiveDuration === 0 || mod.effectiveDuration === -1 || mod.effectiveDuration < 0) { + + if (mod.effectiveDuration === 0 || mod.effectiveDuration === -1 || mod.effectiveDuration < 0) { effectiveExpiry = null; // Permanent effectiveActive = true; // Permanent punishments are always active - } else { - effectiveExpiry = new Date(modDate.getTime() + mod.effectiveDuration); - // Update active status based on whether the new expiry is in the future - const now = new Date(); - effectiveActive = effectiveExpiry.getTime() > now.getTime(); + } else if (mod.date) { + const modDate = new Date(mod.date); + if (!isNaN(modDate.getTime())) { + effectiveExpiry = new Date(modDate.getTime() + mod.effectiveDuration); + const now = new Date(); + effectiveActive = effectiveExpiry.getTime() > now.getTime(); + } } } } @@ -2098,7 +2088,7 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind {mod.type.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (l: string) => l.toUpperCase())} - {formatDateWithTime(mod.issued)} + {formatDateWithTime(mod.date)} {mod.reason && ( diff --git a/client/src/hooks/use-auth.tsx b/client/src/hooks/use-auth.tsx index c6d55a6..445706c 100644 --- a/client/src/hooks/use-auth.tsx +++ b/client/src/hooks/use-auth.tsx @@ -2,7 +2,7 @@ import { createContext, ReactNode, useContext, useState, useEffect } from "react import { useLocation } from "wouter"; import { useToast } from "@modl-gg/shared-web/hooks/use-toast"; import { getApiUrl, getCurrentDomain } from "@/lib/api"; -import { setDateLocale } from "@/utils/date-utils"; +import { setDateLocale, setDateFormat } from "@/utils/date-utils"; import i18n from "@/lib/i18n"; interface User { @@ -12,6 +12,7 @@ interface User { role: 'Super Admin' | 'Admin' | 'Moderator' | 'Helper'; minecraftUsername?: string; // The staff's Minecraft username, used for punishment issuerName language?: string; + dateFormat?: string; } type AuthContextType = { @@ -45,6 +46,7 @@ function mapUserFromMeResponse(userData: any): User { role: userData.role, minecraftUsername: userData.minecraftUsername, language: userData.language || 'en', + dateFormat: userData.dateFormat || 'MM/DD/YYYY', }; } @@ -64,8 +66,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { return mapUserFromMeResponse(userData); }; - // Check for existing session on mount + // Check for existing session on mount (skip on public pages) useEffect(() => { + const path = window.location.pathname; + const isPublicPage = path.startsWith('/ticket/') || + path.startsWith('/appeal') || + path.startsWith('/submit-ticket') || + path === '/' || + path.startsWith('/knowledgebase') || + path.startsWith('/article/'); + + if (isPublicPage) { + setIsLoading(false); + return; + } + const checkSession = async () => { try { const authenticatedUser = await fetchAuthenticatedUser(); @@ -82,12 +97,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { checkSession(); }, []); - // Sync date locale and i18n language when user language changes + // Sync date locale, date format, and i18n language when user settings change useEffect(() => { const lang = user?.language || 'en'; setDateLocale(lang); + setDateFormat(user?.dateFormat || 'MM/DD/YYYY'); i18n.changeLanguage(lang); - }, [user?.language]); + }, [user?.language, user?.dateFormat]); const requestEmailVerification = async (email: string): Promise => { try { diff --git a/client/src/hooks/use-data.tsx b/client/src/hooks/use-data.tsx index 25b681c..2ddf7bb 100644 --- a/client/src/hooks/use-data.tsx +++ b/client/src/hooks/use-data.tsx @@ -128,16 +128,31 @@ export function useTicket(id: string) { return useQuery({ queryKey: ['/v1/public/tickets', id], queryFn: async () => { - const res = await apiFetch(`/v1/public/tickets/${id}`); + // Check for stored ticket auth token + const tokenKey = `ticket_auth_${id}`; + const token = getCookie(tokenKey); + + const url = token + ? `/v1/public/tickets/${id}?token=${encodeURIComponent(token)}` + : `/v1/public/tickets/${id}`; + + const res = await apiFetch(url); if (!res.ok) { if (res.status === 404) { return null; } + if (res.status === 403) { + const data = await res.json().catch(() => ({ requiresVerification: true })); + if (data.requiresVerification) { + return { requiresVerification: true, emailHint: data.emailHint || '', ticketId: id }; + } + } throw new Error('Failed to fetch ticket'); } return res.json(); }, enabled: !!id, + retry: false, staleTime: 0, gcTime: 0, refetchOnMount: true, @@ -145,6 +160,17 @@ export function useTicket(id: string) { }); } +// Cookie helpers for ticket auth tokens +export function setCookie(name: string, value: string, days: number) { + const expires = new Date(Date.now() + days * 864e5).toUTCString(); + document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; +} + +function getCookie(name: string): string | null { + const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? decodeURIComponent(match[2]) : null; +} + export function useCreateTicket() { return useMutation({ mutationFn: async (ticketData: any) => { @@ -195,11 +221,16 @@ export function useUpdateTicket() { export function useAddTicketReply() { return useMutation({ mutationFn: async ({ id, reply }: { id: string, reply: any }) => { - const res = await apiFetch(`/v1/public/tickets/${id}/replies`, { + const tokenKey = `ticket_auth_${id}`; + const token = getCookie(tokenKey); + + const url = token + ? `/v1/public/tickets/${id}/replies?token=${encodeURIComponent(token)}` + : `/v1/public/tickets/${id}/replies`; + + const res = await apiFetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reply) }); @@ -238,6 +269,47 @@ export function useSubmitTicketForm() { }); } +export function useRequestTicketVerification() { + return useMutation({ + mutationFn: async (ticketId: string) => { + const res = await apiFetch(`/v1/public/tickets/${ticketId}/request-verification`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!res.ok) { + const error = await res.json().catch(() => ({})); + throw new Error(error.message || 'Failed to request verification'); + } + + return res.json(); + } + }); +} + +export function useVerifyTicketCode() { + return useMutation({ + mutationFn: async ({ ticketId, code }: { ticketId: string, code: string }) => { + const res = await apiFetch(`/v1/public/tickets/${ticketId}/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ code }) + }); + + if (!res.ok) { + const error = await res.json().catch(() => ({})); + throw new Error(error.message || 'Invalid or expired code'); + } + + return res.json(); + } + }); +} + export function useAppeals() { return useQuery({ queryKey: ['/v1/panel/appeals'], @@ -488,6 +560,24 @@ export function useQuickResponses() { }); } +export function useStatusThresholds() { + return useQuery({ + queryKey: ['/v1/panel/settings/status-thresholds'], + queryFn: async () => { + const res = await apiFetch('/v1/panel/settings/status-thresholds'); + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + return null; + } + throw new Error('Failed to fetch status thresholds'); + } + return res.json(); + }, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); +} + export function useStats() { return useQuery({ queryKey: ['/v1/panel/stats'], diff --git a/client/src/hooks/use-permissions.tsx b/client/src/hooks/use-permissions.tsx index 7c20944..fc690c5 100644 --- a/client/src/hooks/use-permissions.tsx +++ b/client/src/hooks/use-permissions.tsx @@ -165,6 +165,7 @@ export function usePermissions() { if (!response.ok) throw new Error('Failed to fetch roles'); return response.json(); }, + enabled: !!user, staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); diff --git a/client/src/hooks/use-player-lookup.tsx b/client/src/hooks/use-player-lookup.tsx index cdfb1df..8e566e8 100644 --- a/client/src/hooks/use-player-lookup.tsx +++ b/client/src/hooks/use-player-lookup.tsx @@ -88,15 +88,30 @@ export function usePunishmentLookup(punishmentId: string) { queryKey: ['punishment-lookup', punishmentId], queryFn: async (): Promise => { if (!punishmentId) throw new Error('No punishment ID provided'); - - const res = await apiFetch(`/v1/panel/players/punishment-lookup/${punishmentId}`); + + const res = await apiFetch(`/v1/panel/players/punishments/${punishmentId}`); if (!res.ok) { if (res.status === 404) { throw new Error('Punishment not found'); } throw new Error('Failed to lookup punishment'); } - return res.json(); + const data = await res.json(); + // Transform flat PunishmentResponse into the expected shape + return { + playerUuid: data.playerUuid, + playerUsername: data.playerUsername, + punishment: { + id: data.id, + type: data.type, + reason: data.reason, + severity: data.severity, + status: data.status, + issued: data.issued, + expiry: data.expires, + active: data.active, + }, + }; }, enabled: !!punishmentId && punishmentId.length > 0, staleTime: 1000 * 60 * 5, // 5 minutes diff --git a/client/src/lib/i18n.ts b/client/src/lib/i18n.ts index 6fd3314..b10ae70 100644 --- a/client/src/lib/i18n.ts +++ b/client/src/lib/i18n.ts @@ -4,12 +4,14 @@ import { initReactI18next } from 'react-i18next'; import en from '@/locales/en.json'; import de from '@/locales/de.json'; import es from '@/locales/es.json'; +import nl from '@/locales/nl.json'; i18n.use(initReactI18next).init({ resources: { en: { translation: en }, de: { translation: de }, es: { translation: es }, + nl: { translation: nl }, }, lng: 'en', fallbackLng: 'en', diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 6e8157a..30c1432 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -75,7 +75,9 @@ "emailDescription": "Für die Anmeldung und den Empfang von Ticket-Benachrichtigungen.", "minecraftChangeAdmin": "In den Personalverwaltungseinstellungen ändern.", "minecraftChangeContact": "Kontaktiere einen Admin, um dies zu ändern.", - "languageDescription": "Sprache für die Panel-Oberfläche und Datumsformatierung." + "languageDescription": "Sprache für die Panel-Oberfläche.", + "dateFormat": "Datumsformat", + "dateFormatDescription": "Wie Datumsangaben im Panel angezeigt werden." }, "dashboard": { "title": "Dashboard", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 7d04372..8f0db30 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -75,7 +75,9 @@ "emailDescription": "Used for login and receiving ticket notifications.", "minecraftChangeAdmin": "Change this in Staff Management settings.", "minecraftChangeContact": "Contact an admin to change this.", - "languageDescription": "Language for the panel interface and date formatting." + "languageDescription": "Language for the panel interface.", + "dateFormat": "Date Format", + "dateFormatDescription": "How dates are displayed across the panel." }, "dashboard": { "title": "Dashboard", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 265e190..8e7dbc7 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -75,7 +75,9 @@ "emailDescription": "Utilizado para iniciar sesión y recibir notificaciones de tickets.", "minecraftChangeAdmin": "Cambiar en la configuración de gestión de personal.", "minecraftChangeContact": "Contacta a un administrador para cambiar esto.", - "languageDescription": "Idioma para la interfaz del panel y formato de fechas." + "languageDescription": "Idioma para la interfaz del panel.", + "dateFormat": "Formato de fecha", + "dateFormatDescription": "Cómo se muestran las fechas en el panel." }, "dashboard": { "title": "Panel de control", diff --git a/client/src/locales/nl.json b/client/src/locales/nl.json new file mode 100644 index 0000000..19f4e9b --- /dev/null +++ b/client/src/locales/nl.json @@ -0,0 +1,144 @@ +{ + "nav": { + "home": "Home", + "lookup": "Opzoeken", + "tickets": "Tickets", + "audit": "Audit", + "settings": "Instellingen" + }, + "common": { + "save": "Opslaan", + "cancel": "Annuleren", + "delete": "Verwijderen", + "edit": "Bewerken", + "create": "Aanmaken", + "close": "Sluiten", + "search": "Zoeken", + "loading": "Laden...", + "signOut": "Uitloggen", + "update": "Bijwerken", + "confirm": "Bevestigen", + "back": "Terug", + "next": "Volgende", + "submit": "Verzenden", + "reset": "Resetten", + "copy": "Kopiëren", + "copied": "Gekopieerd!", + "yes": "Ja", + "no": "Nee", + "none": "Geen", + "all": "Alle", + "filter": "Filter", + "export": "Exporteren", + "import": "Importeren", + "refresh": "Vernieuwen", + "viewAll": "Alles bekijken", + "showMore": "Meer tonen", + "showLess": "Minder tonen", + "selectAll": "Alles selecteren", + "showing": "Weergave", + "of": "van", + "replies": "Reacties", + "noResults": "Geen resultaten gevonden." + }, + "table": { + "player": "Speler", + "type": "Type", + "reason": "Reden", + "issuedBy": "Uitgegeven door", + "date": "Datum", + "status": "Status", + "duration": "Duur", + "server": "Server", + "actions": "Acties", + "ticket": "Ticket", + "assignee": "Toegewezene", + "lastReply": "Laatste reactie" + }, + "status": { + "active": "Actief", + "inactive": "Inactief", + "expired": "Verlopen", + "pardoned": "Kwijtgescholden", + "open": "Open", + "closed": "Gesloten", + "pending": "In afwachting", + "resolved": "Opgelost" + }, + "settings": { + "profileSettings": "Profielinstellingen", + "panelDisplayName": "Weergavenaam", + "email": "E-mail", + "language": "Taal", + "minecraftUsername": "Minecraft gebruikersnaam", + "displayNameDescription": "Je weergavenaam in ticketgesprekken en interacties.", + "emailDescription": "Gebruikt voor inloggen en het ontvangen van ticketmeldingen.", + "minecraftChangeAdmin": "Wijzig dit in de personeelsbeheer-instellingen.", + "minecraftChangeContact": "Neem contact op met een beheerder om dit te wijzigen.", + "languageDescription": "Taal voor de paneelinterface.", + "dateFormat": "Datumnotatie", + "dateFormatDescription": "Hoe datums worden weergegeven in het paneel." + }, + "dashboard": { + "title": "Dashboard", + "refreshed": "Dashboard vernieuwd", + "refreshedDesc": "Alle dashboardgegevens zijn bijgewerkt.", + "refreshError": "Dashboard vernieuwen mislukt. Probeer het opnieuw." + }, + "tickets": { + "title": "Tickets", + "searchTickets": "Tickets zoeken...", + "loadingTickets": "Tickets laden...", + "noTickets": "Geen tickets die overeenkomen met je huidige filters.", + "label": "Label", + "assignee": "Toegewezene", + "unassigned": "Niet toegewezen", + "newest": "Nieuwste", + "oldest": "Oudste", + "recentlyUpdated": "Recent bijgewerkt", + "leastRecentlyUpdated": "Minst recent bijgewerkt", + "support": "Ondersteuning", + "bugReport": "Bugrapport", + "playerReport": "Spelerrapport", + "chatReport": "Chatrapport", + "banAppeal": "Ban-beroep", + "staffApplication": "Personeelsaanvraag", + "showingTickets": "{{from}}-{{to}} van {{total}} tickets weergegeven" + }, + "audit": { + "title": "Analyse & Audit" + }, + "toast": { + "loginSuccess": "Inloggen gelukt", + "loginSuccessDesc": "Welkom! Je wordt doorgestuurd naar het dashboard...", + "logoutSuccess": "Uitgelogd", + "logoutSuccessDesc": "Je bent succesvol uitgelogd.", + "loginFailed": "Inloggen mislukt", + "loginError": "Inlogfout", + "loginErrorDesc": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.", + "logoutError": "Uitlogfout", + "logoutErrorDesc": "Er is een onverwachte fout opgetreden bij het uitloggen. Clientsessie gewist.", + "networkError": "Netwerkfout", + "networkErrorDesc": "Kan geen verbinding maken met de server om de verificatiecode te verzenden.", + "verificationSent": "Verificatie-e-mail verzonden", + "verificationSentDesc": "Controleer je e-mail voor de verificatiecode.", + "rateLimitExceeded": "Limiet overschreden", + "error": "Fout", + "saveFailed": "Opslaan mislukt", + "permissionDenied": "Geen toestemming", + "workInProgress": "Nog in ontwikkeling", + "workInProgressDesc": "Deze functie is momenteel niet beschikbaar.", + "success": "Gelukt" + }, + "search": { + "searchPlayers": "Spelers zoeken...", + "searchPunishmentIds": "Gebruik # om straf-ID's te zoeken", + "recentSearches": "Recente zoekopdrachten", + "playersFound": "Spelers gevonden", + "punishmentFound": "Straf gevonden", + "punishmentLookup": "Straf opzoeken", + "noPlayersFound": "Geen spelers gevonden voor '{{query}}'", + "startTyping": "Begin met typen om spelers te zoeken", + "enterPunishmentId": "Voer een straf-ID in na het #-symbool" + } +} diff --git a/client/src/pages/audit.tsx b/client/src/pages/audit.tsx index 72792bb..1ce4690 100644 --- a/client/src/pages/audit.tsx +++ b/client/src/pages/audit.tsx @@ -19,7 +19,12 @@ import { Users, Clock, Undo2, - Gavel + Gavel, + ArrowUpDown, + Filter, + Paperclip, + ChevronLeft, + ChevronRight } from 'lucide-react'; import { getApiUrl, getCurrentDomain, apiFetch } from '@/lib/api'; import { Button } from '@modl-gg/shared-web/components/ui/button'; @@ -29,7 +34,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Popover, PopoverContent, PopoverTrigger } from '@modl-gg/shared-web/components/ui/popover'; import { Calendar as CalendarComponent } from '@modl-gg/shared-web/components/ui/calendar'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogPortal } from '@modl-gg/shared-web/components/ui/dialog'; -import { format, subDays } from 'date-fns'; +import { subDays } from 'date-fns'; +import { formatDateOnly } from '@/utils/date-utils'; import { useLogs } from '@/hooks/use-data'; import { useQuery } from '@tanstack/react-query'; import PageContainer from '@/components/layout/PageContainer'; @@ -151,7 +157,7 @@ const formatRelativeTime = (date: Date) => { if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; - return format(date, 'MMM d, yyyy'); + return formatDateOnly(date); }; const formatDurationDetailed = (date: Date) => { @@ -234,6 +240,34 @@ const fetchPunishments = async (limit = 50, canRollback = true): Promise; + attachedTicketIds: string[]; +} + +const fetchPunishmentsList = async (status: string): Promise => { + const response = await fetch(getApiUrl(`/v1/panel/audit/punishments/active?status=${status}`), { + credentials: 'include', + headers: { 'X-Server-Domain': getCurrentDomain() } + }); + if (!response.ok) throw new Error('Failed to fetch punishments'); + return response.json(); +}; + // Custom themed tooltip component for charts const CustomTooltip = ({ active, payload, label, formatValue, formatName }: any) => { if (active && payload && payload.length) { @@ -654,7 +688,7 @@ const StaffDetailModal = ({ staff, isOpen, onClose, initialPeriod = '30d' }: { body: JSON.stringify({ startDate: rollbackStartDate.toISOString(), endDate: rollbackEndDate.toISOString(), - reason: `Bulk rollback for ${staff.username} from ${format(rollbackStartDate, 'MMM d, yyyy')} to ${format(rollbackEndDate, 'MMM d, yyyy')}` + reason: `Bulk rollback for ${staff.username} from ${formatDateOnly(rollbackStartDate)} to ${formatDateOnly(rollbackEndDate)}` }) }); @@ -745,7 +779,7 @@ const StaffDetailModal = ({ staff, isOpen, onClose, initialPeriod = '30d' }: { className="w-full justify-start text-left font-normal h-8" > - {rollbackStartDate ? format(rollbackStartDate, "MMM d, yyyy") : "Select start"} + {rollbackStartDate ? formatDateOnly(rollbackStartDate) : "Select start"} @@ -768,7 +802,7 @@ const StaffDetailModal = ({ staff, isOpen, onClose, initialPeriod = '30d' }: { className="w-full justify-start text-left font-normal h-8" > - {rollbackEndDate ? format(rollbackEndDate, "MMM d, yyyy") : "Select end"} + {rollbackEndDate ? formatDateOnly(rollbackEndDate) : "Select end"} @@ -1069,7 +1103,7 @@ const StaffDetailModal = ({ staff, isOpen, onClose, initialPeriod = '30d' }: { {formatDuration(punishment.duration)} - {format(new Date(punishment.issued), 'MMM d, yyyy')} + {formatDateOnly(new Date(punishment.issued))}
@@ -1420,6 +1454,333 @@ const TicketAnalyticsSection = ({ analyticsPeriod }: { analyticsPeriod: string } ); }; +// Punishments List Card component +const ActivePunishmentsCard = () => { + const [statusFilter, setStatusFilter] = useState('active'); + const [staffFilter, setStaffFilter] = useState('all'); + const [typeFilter, setTypeFilter] = useState('all'); + const [evidenceFilter, setEvidenceFilter] = useState('all'); + const [sortBy, setSortBy] = useState('issued'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + const [page, setPage] = useState(0); + const pageSize = 20; + const { openPlayerWindow } = usePlayerWindow(); + + const { data: activePunishments = [], isLoading } = useQuery({ + queryKey: ['punishments-list', statusFilter], + queryFn: () => fetchPunishmentsList(statusFilter), + staleTime: 5 * 60 * 1000 + }); + + // Extract unique staff names and categories for filter options + const staffNames = useMemo(() => { + const names = new Set(activePunishments.map(p => p.staffName)); + return Array.from(names).sort(); + }, [activePunishments]); + + const punishmentTypes = useMemo(() => { + const types = new Set(activePunishments.map(p => p.type)); + return Array.from(types).sort(); + }, [activePunishments]); + + // Filter and sort + const filteredPunishments = useMemo(() => { + let filtered = [...activePunishments]; + + if (staffFilter !== 'all') { + filtered = filtered.filter(p => p.staffName === staffFilter); + } + if (typeFilter !== 'all') { + filtered = filtered.filter(p => p.type === typeFilter); + } + if (evidenceFilter === 'yes') { + filtered = filtered.filter(p => p.hasEvidence); + } else if (evidenceFilter === 'no') { + filtered = filtered.filter(p => !p.hasEvidence); + } + + filtered.sort((a, b) => { + let valA: number, valB: number; + switch (sortBy) { + case 'issued': + valA = new Date(a.issued).getTime(); + valB = new Date(b.issued).getTime(); + break; + case 'started': + valA = a.started ? new Date(a.started).getTime() : 0; + valB = b.started ? new Date(b.started).getTime() : 0; + break; + case 'duration': + valA = a.duration ?? -1; + valB = b.duration ?? -1; + break; + default: + valA = new Date(a.issued).getTime(); + valB = new Date(b.issued).getTime(); + } + return sortDir === 'desc' ? valB - valA : valA - valB; + }); + + return filtered; + }, [activePunishments, staffFilter, typeFilter, evidenceFilter, sortBy, sortDir]); + + // Reset page when filters change + useEffect(() => { + setPage(0); + }, [statusFilter, staffFilter, typeFilter, evidenceFilter, sortBy, sortDir]); + + const totalPages = Math.ceil(filteredPunishments.length / pageSize); + const paginatedPunishments = filteredPunishments.slice(page * pageSize, (page + 1) * pageSize); + + const toggleSort = (field: string) => { + if (sortBy === field) { + setSortDir(prev => prev === 'desc' ? 'asc' : 'desc'); + } else { + setSortBy(field); + setSortDir('desc'); + } + }; + + const formatDuration = (duration: number | null) => { + if (duration === null || duration === undefined || duration <= 0) return 'Permanent'; + const days = Math.floor(duration / (1000 * 60 * 60 * 24)); + const hours = Math.floor((duration % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60)); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + }; + + const formatTimeRemaining = (expires: string | null) => { + if (!expires) return 'Permanent'; + const expiryDate = new Date(expires); + const now = new Date(); + const diffMs = expiryDate.getTime() - now.getTime(); + if (diffMs <= 0) return 'Expiring...'; + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + if (days > 0) return `${days}d ${hours}h left`; + if (hours > 0) return `${hours}h left`; + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + return `${minutes}m left`; + }; + + const SortHeader = ({ field, label }: { field: string; label: string }) => ( + toggleSort(field)} + > +
+ {label} + +
+ + ); + + return ( + + +
+ + + Punishments List + {filteredPunishments.length} + + +
+
+ + +
+ + + + + + +
+
+
+ + {isLoading ? ( +
+ +
+ ) : filteredPunishments.length === 0 ? ( +
+
+ +

+ {activePunishments.length === 0 ? 'No punishments found' : 'No punishments match the selected filters'} +

+
+
+ ) : ( + <> +
+ + + + + + {statusFilter !== 'active' && } + + + + + + + + + + {paginatedPunishments.map((punishment) => ( + + + + {statusFilter !== 'active' && ( + + )} + + + + + + + + ))} + +
PlayerTypeStatusStaffRemainingEvidence
+ + +
+ + {punishment.type} + + + {punishment.category} + +
+
+ + {punishment.active ? 'Active' : 'Inactive'} + + {punishment.staffName}{formatDateOnly(new Date(punishment.issued))} + {punishment.started ? formatDateOnly(new Date(punishment.started)) : --} + {formatDuration(punishment.duration)} + {!punishment.active ? ( + n/a + ) : ( + + {formatTimeRemaining(punishment.expires)} + + )} + + {punishment.hasEvidence ? ( +
+ {punishment.evidence.map((ev, idx) => ( + ev.url && window.open(ev.url, '_blank')} + title={ev.text || ev.fileName || 'Evidence'} + > + + {ev.fileName || ev.type || `#${idx + 1}`} + + ))} +
+ ) : ( + None + )} +
+
+ {totalPages > 1 && ( +
+ + Showing {page * pageSize + 1}-{Math.min((page + 1) * pageSize, filteredPunishments.length)} of {filteredPunishments.length} + +
+ + + {page + 1} / {totalPages} + + +
+
+ )} + + )} +
+
+ ); +}; + // Stat card component that toggles expansion const StatCard = ({ title, @@ -1808,6 +2169,8 @@ const AuditLog = () => { )} + +
)} diff --git a/client/src/pages/player-detail-page.tsx b/client/src/pages/player-detail-page.tsx index bd24332..0f255a9 100644 --- a/client/src/pages/player-detail-page.tsx +++ b/client/src/pages/player-detail-page.tsx @@ -387,30 +387,33 @@ const PlayerDetailPage = () => { let hasModifications = modifications.length > 0; // Process modifications in chronological order (oldest first) - const sortedModifications = [...modifications].sort((a, b) => - new Date(a.issued).getTime() - new Date(b.issued).getTime() - ); - + const sortedModifications = [...modifications].sort((a: any, b: any) => { + const dateA = a.date ? new Date(a.date).getTime() : 0; + const dateB = b.date ? new Date(b.date).getTime() : 0; + return dateA - dateB; + }); + for (const mod of sortedModifications) { switch (mod.type) { case 'MANUAL_PARDON': case 'APPEAL_ACCEPT': effectiveActive = false; - effectiveExpiry = mod.issued; // Set expiry to when it was pardoned + effectiveExpiry = mod.date; // Set expiry to when it was pardoned break; case 'MANUAL_DURATION_CHANGE': + case 'APPEAL_DURATION_CHANGE': if (mod.effectiveDuration !== undefined) { effectiveDuration = mod.effectiveDuration; - // Calculate new expiry based on new duration and punishment start time if (mod.effectiveDuration === 0 || mod.effectiveDuration === -1 || mod.effectiveDuration < 0) { effectiveExpiry = null; // Permanent - } else if (punishment.issued || punishment.date) { - const startTime = new Date(punishment.issued || punishment.date); - effectiveExpiry = new Date(startTime.getTime() + mod.effectiveDuration).toISOString(); + } else if (mod.date) { + const modDate = new Date(mod.date); + if (!isNaN(modDate.getTime())) { + effectiveExpiry = new Date(modDate.getTime() + mod.effectiveDuration).toISOString(); + } } } break; - // Add other modification types as needed } } @@ -1558,7 +1561,7 @@ const PlayerDetailPage = () => { {mod.type.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (l: string) => l.toUpperCase())} - {formatDateWithTime(mod.issued)} + {formatDateWithTime(mod.date)} {mod.reason && ( diff --git a/client/src/pages/player-ticket.tsx b/client/src/pages/player-ticket.tsx index 821d5a1..e40cd53 100644 --- a/client/src/pages/player-ticket.tsx +++ b/client/src/pages/player-ticket.tsx @@ -24,7 +24,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Popover, PopoverContent, PopoverTrigger } from '@modl-gg/shared-web/components/ui/popover'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@modl-gg/shared-web/components/ui/tooltip'; import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@modl-gg/shared-web/components/ui/card'; -import { useTicket, useAddTicketReply, useSubmitTicketForm, useSettings } from '@/hooks/use-data'; +import { useTicket, useAddTicketReply, useSubmitTicketForm, useSettings, useRequestTicketVerification, useVerifyTicketCode, setCookie } from '@/hooks/use-data'; import TicketAttachments from '@/components/TicketAttachments'; import MediaUpload from '@/components/MediaUpload'; import { apiRequest } from '@/lib/queryClient'; @@ -199,6 +199,14 @@ const PlayerTicket = () => { // Mutation hooks for public ticket operations const addReplyMutation = useAddTicketReply(); const submitFormMutation = useSubmitTicketForm(); + const requestVerificationMutation = useRequestTicketVerification(); + const verifyCodeMutation = useVerifyTicketCode(); + + // Email verification state + const [verificationCode, setVerificationCode] = useState(''); + const [verificationEmailHint, setVerificationEmailHint] = useState(''); + const [isVerifying, setIsVerifying] = useState(false); + const [codeSent, setCodeSent] = useState(false); const [ticketDetails, setTicketDetails] = useState({ id: "", @@ -390,6 +398,107 @@ const PlayerTicket = () => { ); } + // Email verification gate + if (ticketData?.requiresVerification) { + const handleRequestCode = async () => { + try { + const result = await requestVerificationMutation.mutateAsync(id || ''); + setVerificationEmailHint(result.emailHint || ticketData.emailHint || ''); + setCodeSent(true); + toast({ + title: "Code Sent", + description: "A verification code has been sent to your email.", + }); + } catch (error: any) { + toast({ + title: "Failed to send code", + description: error.message || "Please try again.", + variant: "destructive" + }); + } + }; + + const handleVerifyCode = async () => { + if (!verificationCode.trim()) return; + setIsVerifying(true); + try { + const result = await verifyCodeMutation.mutateAsync({ + ticketId: id || '', + code: verificationCode.trim() + }); + if (result.token) { + setCookie(`ticket_auth_${id}`, result.token, 7); + // Refetch the ticket with the new token + queryClient.invalidateQueries({ queryKey: ['/v1/public/tickets', id] }); + toast({ + title: "Verified", + description: "Access granted. You can now view this ticket.", + }); + } + } catch (error: any) { + toast({ + title: "Verification Failed", + description: error.message || "Invalid or expired code. Please try again.", + variant: "destructive" + }); + } finally { + setIsVerifying(false); + } + }; + + return ( +
+ + + Email Verification Required + + This ticket requires email verification to view. + {ticketData.emailHint && ( + <> A code will be sent to {ticketData.emailHint}. + )} + + + + {!codeSent ? ( + + ) : ( + <> +
+ + setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + maxLength={6} + className="text-center text-lg tracking-widest" + /> +
+ + + + )} +
+
+
+ ); + } + // Handle form submissions for unfinished tickets @@ -981,8 +1090,27 @@ const PlayerTicket = () => { ); })} + {/* Email authentication option */} +
+
+ handleFormFieldChange('emailAuthEnabled', checked ? 'true' : 'false')} + className="mt-1" + /> +
+ +

+ Require email verification to access this ticket. If disabled, anyone with the ticket ID can view and reply. +

+
+
+
- +
+
+ )} diff --git a/client/src/pages/tickets.tsx b/client/src/pages/tickets.tsx index d2ec817..78b79cf 100644 --- a/client/src/pages/tickets.tsx +++ b/client/src/pages/tickets.tsx @@ -9,6 +9,7 @@ import { ChevronLeft, ChevronRight, Eye, + EyeOff, User, SortAsc, Plus @@ -39,6 +40,7 @@ interface Ticket { date: string; status: string; locked?: boolean; + hidden?: boolean; tags?: string[]; assignedTo?: string; lastReply?: { @@ -290,6 +292,12 @@ const Tickets = () => {
{ticket.subject} + {ticket.hidden && ( + + + Hidden + + )} {ticketLabels.map((tagName) => { const label = labels.find((l) => l.name === tagName); return ( @@ -397,6 +405,11 @@ const Tickets = () => { )} {ticket.subject} + {ticket.hidden && ( + + + + )}
{ticketLabels.map((tagName) => { diff --git a/client/src/utils/date-utils.ts b/client/src/utils/date-utils.ts index 5536274..b45c4d6 100644 --- a/client/src/utils/date-utils.ts +++ b/client/src/utils/date-utils.ts @@ -2,42 +2,69 @@ * Shared date and time formatting utilities */ -let _currentLocale = 'en-US'; +let _dateFormat = 'MM/DD/YYYY'; -const langToLocaleMap: Record = { - en: 'en-US', - de: 'de-DE', - es: 'es-ES', +export const setDateFormat = (fmt: string) => { + _dateFormat = fmt; }; -export const setDateLocale = (lang: string) => { - _currentLocale = langToLocaleMap[lang] || 'en-US'; -}; +export const getDateFormat = () => _dateFormat; + +// Keep setDateLocale as a no-op for backwards compat (called from use-auth) +export const setDateLocale = (_lang: string) => {}; + +const pad = (n: number): string => n.toString().padStart(2, '0'); + +const formatDateParts = (date: Date, includeTime: boolean): string => { + const mm = pad(date.getMonth() + 1); + const dd = pad(date.getDate()); + const yyyy = date.getFullYear().toString(); + + let datePart: string; + switch (_dateFormat) { + case 'DD/MM/YYYY': + datePart = `${dd}/${mm}/${yyyy}`; + break; + case 'YYYY-MM-DD': + datePart = `${yyyy}-${mm}-${dd}`; + break; + default: // MM/DD/YYYY + datePart = `${mm}/${dd}/${yyyy}`; + break; + } + + if (!includeTime) return datePart; -export const getDateLocale = () => _currentLocale; + const hh = pad(date.getHours()); + const min = pad(date.getMinutes()); + return `${datePart} ${hh}:${min}`; +}; export const formatDate = (dateString: string): string => { try { - // Handle various date formats and edge cases if (!dateString || dateString === 'Invalid Date') { return 'Unknown'; } const date = new Date(dateString); - // Check if the date is valid if (isNaN(date.getTime())) { return 'Invalid Date'; } - return date.toLocaleString(_currentLocale, { - month: '2-digit', - day: '2-digit', - year: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }); + return formatDateParts(date, true); + } catch (error) { + return 'Invalid Date'; + } +}; + +export const formatDateOnly = (date: Date | string): string => { + try { + const dateObj = typeof date === 'string' ? new Date(date) : date; + if (isNaN(dateObj.getTime())) { + return 'Invalid Date'; + } + return formatDateParts(dateObj, false); } catch (error) { return 'Invalid Date'; } @@ -48,19 +75,11 @@ export const formatDateWithTime = (date: Date | string | null | undefined): stri const dateObj = typeof date === 'string' ? new Date(date) : date; - // Check if the date is valid if (isNaN(dateObj.getTime())) { return 'Invalid Date'; } - return new Intl.DateTimeFormat(_currentLocale, { - month: '2-digit', - day: '2-digit', - year: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }).format(dateObj); + return formatDateParts(dateObj, true); }; export const formatTimeAgo = (dateString: string | Date): string => { @@ -103,15 +122,7 @@ export const formatDateWithRelative = (dateString: string): string => { const now = new Date(); const timeDiff = date.getTime() - now.getTime(); - // Format the actual date - const formattedDate = date.toLocaleString(_currentLocale, { - month: '2-digit', - day: '2-digit', - year: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }); + const formattedDate = formatDateParts(date, true); // Calculate relative time const absDiff = Math.abs(timeDiff);