)}
- {/* 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());
- }}
- />
- ))}
- );
- 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(
-
;
- }
-
// 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 */}
-
+
+
+
+
{/* 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 = ({