@@ -1069,7 +1070,7 @@ const StaffDetailModal = ({ staff, isOpen, onClose, initialPeriod = '30d' }: {
| {formatDuration(punishment.duration)} |
- {format(new Date(punishment.issued), 'MMM d, yyyy')} |
+ {formatDateOnly(new Date(punishment.issued))} |
diff --git a/client/src/pages/settings.tsx b/client/src/pages/settings.tsx
index 0f5fc30..1cd7be5 100644
--- a/client/src/pages/settings.tsx
+++ b/client/src/pages/settings.tsx
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Scale, Shield, Globe, Tag, Plus, X, Fingerprint, KeyRound, Lock, QrCode, Copy, Check, Mail, Trash2, GamepadIcon, MessageCircle, Save, CheckCircle, User as UserIcon, CreditCard, BookOpen, Settings as SettingsIcon, Upload, Key, Eye, EyeOff, RefreshCw, ChevronDown, ChevronRight, Layers, GripVertical, Edit3, Users, Bot, FileText, Home, Bell, Crown, Database } from 'lucide-react';
import { getApiUrl, getCurrentDomain, apiFetch, apiUpload } from '@/lib/api';
-import { setDateLocale } from '@/utils/date-utils';
+import { setDateLocale, setDateFormat as setDateFormatUtil } from '@/utils/date-utils';
import i18n from '@/lib/i18n';
import { Button } from '@modl-gg/shared-web/components/ui/button';
import { Card, CardContent, CardHeader } from '@modl-gg/shared-web/components/ui/card';
@@ -852,6 +852,7 @@ const Settings = () => {
// Refs to capture latest profile values for auto-save
const profileUsernameRef = useRef('');
const languageRef = useRef('en');
+ const dateFormatRef = useRef('MM/DD/YYYY');
// Database connection state
const [dbConnectionStatus, setDbConnectionStatus] = useState(false);
@@ -1011,6 +1012,7 @@ const Settings = () => {
const [apiKeyCopied, setApiKeyCopied] = useState(false); // Profile settings state
const [profileUsernameState, setProfileUsernameState] = useState('');
const [languageState, setLanguageState] = useState('en');
+ const [dateFormatState, setDateFormatState] = useState('MM/DD/YYYY');
// AI Moderation settings state
const [aiModerationSettings, setAiModerationSettings] = useState({
@@ -1209,10 +1211,12 @@ const Settings = () => {
justLoadedFromServerRef.current = true; // Prevent auto-save during initial load
setProfileUsernameState(user.username || '');
setLanguageState(user.language || 'en');
+ setDateFormatState(user.dateFormat || 'MM/DD/YYYY');
// Initialize the refs with the current values
profileUsernameRef.current = user.username || '';
languageRef.current = user.language || 'en';
+ dateFormatRef.current = user.dateFormat || 'MM/DD/YYYY';
// Mark profile data as loaded after a short delay
setTimeout(() => {
@@ -2194,6 +2198,18 @@ const Settings = () => {
triggerProfileAutoSave();
}
};
+
+ const setDateFormat = (value: string) => {
+ setDateFormatState(value);
+ dateFormatRef.current = value;
+ setDateFormatUtil(value);
+ if (user) {
+ user.dateFormat = value;
+ }
+ if (!justLoadedFromServerRef.current && initialLoadCompletedRef.current) {
+ triggerProfileAutoSave();
+ }
+ };
// Save profile settings function
const saveProfileSettings = useCallback(async () => {
@@ -2207,7 +2223,8 @@ const Settings = () => {
credentials: 'include',
body: JSON.stringify({
username: profileUsernameState,
- language: languageState
+ language: languageState,
+ dateFormat: dateFormatState
})
});
@@ -2251,7 +2268,7 @@ const Settings = () => {
description: "Failed to save profile. Please try again.", variant: "destructive",
});
}
- }, [profileUsernameState, languageState, user, toast, setLastSaved]);
+ }, [profileUsernameState, languageState, dateFormatState, user, toast, setLastSaved]);
// Auto-save function for profile settings
const triggerProfileAutoSave = useCallback(() => {
@@ -2263,6 +2280,7 @@ const Settings = () => {
// Use refs to get the latest values at execution time
const currentUsername = profileUsernameRef.current;
const currentLanguage = languageRef.current;
+ const currentDateFormat = dateFormatRef.current;
// Skip save if username is empty
if (!currentUsername.trim()) {
@@ -2278,7 +2296,8 @@ const Settings = () => {
},
body: JSON.stringify({
username: currentUsername,
- language: currentLanguage
+ language: currentLanguage,
+ dateFormat: currentDateFormat
})
});
@@ -3210,6 +3229,8 @@ const Settings = () => {
userRole={user?.role}
language={languageState}
setLanguage={setLanguage}
+ dateFormat={dateFormatState}
+ setDateFormat={setDateFormat}
/>
)}
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);
From ffc16139de14881058684391cb355669d99c4364 Mon Sep 17 00:00:00 2001
From: Theo
Date: Fri, 13 Feb 2026 02:27:05 -0800
Subject: [PATCH 04/13] dutch, fixes
---
.../components/settings/AccountSettings.tsx | 1 +
.../src/components/windows/PlayerWindow.tsx | 66 ++++----
client/src/lib/i18n.ts | 2 +
client/src/locales/nl.json | 144 ++++++++++++++++++
client/src/pages/player-detail-page.tsx | 23 +--
5 files changed, 188 insertions(+), 48 deletions(-)
create mode 100644 client/src/locales/nl.json
diff --git a/client/src/components/settings/AccountSettings.tsx b/client/src/components/settings/AccountSettings.tsx
index e0d8015..c5f60a5 100644
--- a/client/src/components/settings/AccountSettings.tsx
+++ b/client/src/components/settings/AccountSettings.tsx
@@ -124,6 +124,7 @@ const AccountSettings = ({
English
Deutsch
Español
+ Nederlands
diff --git a/client/src/components/windows/PlayerWindow.tsx b/client/src/components/windows/PlayerWindow.tsx
index 30ccbe3..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();
+ }
}
}
}
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/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/player-detail-page.tsx b/client/src/pages/player-detail-page.tsx
index a26800d..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
}
}
From 61c6f5667dd0dce648a488f0f72359917e0963aa Mon Sep 17 00:00:00 2001
From: Theo
Date: Fri, 13 Feb 2026 17:20:06 -0800
Subject: [PATCH 05/13] ticket security + active punishments
---
client/src/hooks/use-data.tsx | 83 +++++++-
client/src/pages/audit.tsx | 297 ++++++++++++++++++++++++++++-
client/src/pages/player-ticket.tsx | 145 +++++++++++++-
client/src/pages/submit-ticket.tsx | 20 ++
client/src/pages/ticket-detail.tsx | 51 ++++-
client/src/pages/tickets.tsx | 13 ++
6 files changed, 594 insertions(+), 15 deletions(-)
diff --git a/client/src/hooks/use-data.tsx b/client/src/hooks/use-data.tsx
index a407d31..c7d1c81 100644
--- a/client/src/hooks/use-data.tsx
+++ b/client/src/hooks/use-data.tsx
@@ -128,11 +128,26 @@ 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 headers: Record = {};
+ if (token) {
+ headers['X-Ticket-Token'] = token;
+ }
+
+ const res = await apiFetch(`/v1/public/tickets/${id}`, { headers });
if (!res.ok) {
if (res.status === 404) {
return null;
}
+ if (res.status === 403) {
+ const data = await res.json().catch(() => ({}));
+ if (data.requiresVerification) {
+ return { requiresVerification: true, emailHint: data.emailHint || '', ticketId: id };
+ }
+ }
throw new Error('Failed to fetch ticket');
}
return res.json();
@@ -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,19 @@ export function useUpdateTicket() {
export function useAddTicketReply() {
return useMutation({
mutationFn: async ({ id, reply }: { id: string, reply: any }) => {
+ const tokenKey = `ticket_auth_${id}`;
+ const token = getCookie(tokenKey);
+
+ const headers: Record = {
+ 'Content-Type': 'application/json'
+ };
+ if (token) {
+ headers['X-Ticket-Token'] = token;
+ }
+
const res = await apiFetch(`/v1/public/tickets/${id}/replies`, {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
+ headers,
body: JSON.stringify(reply)
});
@@ -238,6 +272,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'],
diff --git a/client/src/pages/audit.tsx b/client/src/pages/audit.tsx
index 3e0f059..7d8ce34 100644
--- a/client/src/pages/audit.tsx
+++ b/client/src/pages/audit.tsx
@@ -19,7 +19,10 @@ import {
Users,
Clock,
Undo2,
- Gavel
+ Gavel,
+ ArrowUpDown,
+ Filter,
+ Paperclip
} from 'lucide-react';
import { getApiUrl, getCurrentDomain, apiFetch } from '@/lib/api';
import { Button } from '@modl-gg/shared-web/components/ui/button';
@@ -235,6 +238,33 @@ const fetchPunishments = async (limit = 50, canRollback = true): Promise;
+ attachedTicketIds: string[];
+}
+
+const fetchActivePunishments = async (): Promise => {
+ const response = await fetch(getApiUrl('/v1/panel/audit/punishments/active'), {
+ credentials: 'include',
+ headers: { 'X-Server-Domain': getCurrentDomain() }
+ });
+ if (!response.ok) throw new Error('Failed to fetch active punishments');
+ return response.json();
+};
+
// Custom themed tooltip component for charts
const CustomTooltip = ({ active, payload, label, formatValue, formatName }: any) => {
if (active && payload && payload.length) {
@@ -1421,6 +1451,269 @@ const TicketAnalyticsSection = ({ analyticsPeriod }: { analyticsPeriod: string }
);
};
+// Active Punishments Card component
+const ActivePunishmentsCard = () => {
+ const [staffFilter, setStaffFilter] = useState('all');
+ const [categoryFilter, setCategoryFilter] = useState('all');
+ const [evidenceFilter, setEvidenceFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('issued');
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
+ const { openPlayerWindow } = usePlayerWindow();
+
+ const { data: activePunishments = [], isLoading } = useQuery({
+ queryKey: ['active-punishments'],
+ queryFn: fetchActivePunishments,
+ 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 categories = useMemo(() => {
+ const cats = new Set(activePunishments.map(p => p.category));
+ return Array.from(cats).sort();
+ }, [activePunishments]);
+
+ // Filter and sort
+ const filteredPunishments = useMemo(() => {
+ let filtered = [...activePunishments];
+
+ if (staffFilter !== 'all') {
+ filtered = filtered.filter(p => p.staffName === staffFilter);
+ }
+ if (categoryFilter !== 'all') {
+ filtered = filtered.filter(p => p.category === categoryFilter);
+ }
+ 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, categoryFilter, evidenceFilter, sortBy, sortDir]);
+
+ 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)}
+ >
+
+ |
+ );
+
+ return (
+
+
+
+
+
+ Active Punishments
+ {filteredPunishments.length}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : filteredPunishments.length === 0 ? (
+
+
+
+
+ {activePunishments.length === 0 ? 'No active punishments' : 'No punishments match the selected filters'}
+
+
+
+ ) : (
+
+
+
+
+ | Player |
+ Type |
+ Staff |
+
+
+
+ Remaining |
+ Evidence |
+
+
+
+ {filteredPunishments.map((punishment) => (
+
+ |
+
+ |
+
+
+
+ {punishment.type}
+
+
+ {punishment.category}
+
+
+ |
+ {punishment.staffName} |
+ {formatDateOnly(new Date(punishment.issued))} |
+
+ {punishment.started ? formatDateOnly(new Date(punishment.started)) : --}
+ |
+ {formatDuration(punishment.duration)} |
+
+
+ {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
+ )}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ );
+};
+
// Stat card component that toggles expansion
const StatCard = ({
title,
@@ -1809,6 +2102,8 @@ const AuditLog = () => {
)}
+
+
)}
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.
+
+
+
+
-
+
|