import { useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { rbacApi, AdminRole, PermissionSection, CreateRolePayload, UpdateRolePayload, } from '@/api/rbac'; import { PermissionGate } from '@/components/auth/PermissionGate'; import { usePermissionStore } from '@/store/permissions'; import { usePlatform } from '@/platform/hooks/usePlatform'; // === Icons === const BackIcon = () => ( ); const PlusIcon = () => ( ); const EditIcon = () => ( ); const TrashIcon = () => ( ); const ChevronDownIcon = ({ className }: { className?: string }) => ( ); const XMarkIcon = () => ( ); const ShieldIcon = () => ( ); // === Constants === const ROLE_COLORS = [ '#6366f1', // indigo '#8b5cf6', // violet '#a855f7', // purple '#ec4899', // pink '#ef4444', // red '#f97316', // orange '#eab308', // yellow '#22c55e', // green '#14b8a6', // teal '#06b6d4', // cyan '#3b82f6', // blue '#6b7280', // gray ]; const PRESETS: Record = { moderator: ['users:read', 'users:edit', 'users:block', 'tickets:*', 'ban_system:*'], marketer: [ 'campaigns:*', 'broadcasts:*', 'promocodes:*', 'promo_offers:*', 'promo_groups:*', 'stats:read', 'pinned_messages:*', 'wheel:*', ], support: ['tickets:read', 'tickets:reply', 'users:read'], }; // === Types === interface RoleFormData { name: string; description: string; level: number; color: string; permissions: string[]; } const INITIAL_FORM: RoleFormData = { name: '', description: '', level: 50, color: ROLE_COLORS[0], permissions: [], }; // === Sub-components === interface PermissionMatrixProps { registry: PermissionSection[]; selectedPermissions: string[]; onToggle: (perm: string) => void; onToggleSection: (section: string, actions: string[]) => void; } function PermissionMatrix({ registry, selectedPermissions, onToggle, onToggleSection, }: PermissionMatrixProps) { const { t } = useTranslation(); const [expanded, setExpanded] = useState>({}); const toggleExpand = useCallback((section: string) => { setExpanded((prev) => ({ ...prev, [section]: !prev[section] })); }, []); const isSectionFullySelected = useCallback( (section: string, actions: string[]) => { return actions.every((action) => { const perm = `${section}:${action}`; return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`); }); }, [selectedPermissions], ); const isSectionPartiallySelected = useCallback( (section: string, actions: string[]) => { const hasAny = actions.some((action) => { const perm = `${section}:${action}`; return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`); }); return hasAny && !isSectionFullySelected(section, actions); }, [selectedPermissions, isSectionFullySelected], ); const isPermSelected = useCallback( (section: string, action: string) => { const perm = `${section}:${action}`; return selectedPermissions.includes(perm) || selectedPermissions.includes(`${section}:*`); }, [selectedPermissions], ); return ( {t('admin.roles.form.permissions')} {registry.map((section) => { const isExpanded = expanded[section.section] ?? false; const allSelected = isSectionFullySelected(section.section, section.actions); const partialSelected = isSectionPartiallySelected(section.section, section.actions); return ( {/* Section header */} onToggleSection(section.section, section.actions)} className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${ allSelected ? 'border-accent-500 bg-accent-500' : partialSelected ? 'border-accent-500 bg-accent-500/40' : 'border-dark-500 hover:border-dark-400' }`} aria-label={t('admin.roles.form.toggleSection', { section: section.section, })} > {(allSelected || partialSelected) && ( {allSelected ? ( ) : ( )} )} toggleExpand(section.section)} className="flex flex-1 items-center justify-between" > {section.section} {section.actions.filter((a) => isPermSelected(section.section, a)).length}/ {section.actions.length} {/* Actions list */} {isExpanded && ( {section.actions.map((action) => { const perm = `${section.section}:${action}`; const selected = isPermSelected(section.section, action); return ( onToggle(perm)} className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${ selected ? 'bg-accent-500/20 text-accent-400' : 'bg-dark-700/50 text-dark-400 hover:bg-dark-700 hover:text-dark-300' }`} aria-pressed={selected} > {action} ); })} )} ); })} ); } // === Main Page === export default function AdminRoles() { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { capabilities } = usePlatform(); const canManageRole = usePermissionStore((s) => s.canManageRole); // Modal state const [modalOpen, setModalOpen] = useState(false); const [editingRole, setEditingRole] = useState(null); const [formData, setFormData] = useState(INITIAL_FORM); const [deleteConfirm, setDeleteConfirm] = useState(null); const [formError, setFormError] = useState(null); // Queries const { data: roles, isLoading: rolesLoading, error: rolesError, } = useQuery({ queryKey: ['admin-roles'], queryFn: rbacApi.getRoles, }); const { data: permissionRegistry } = useQuery({ queryKey: ['admin-permission-registry'], queryFn: rbacApi.getPermissionRegistry, }); // Mutations const createMutation = useMutation({ mutationFn: (payload: CreateRolePayload) => rbacApi.createRole(payload), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-roles'] }); closeModal(); }, onError: () => { setFormError(t('admin.roles.errors.createFailed')); }, }); const updateMutation = useMutation({ mutationFn: ({ id, payload }: { id: number; payload: UpdateRolePayload }) => rbacApi.updateRole(id, payload), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-roles'] }); closeModal(); }, onError: () => { setFormError(t('admin.roles.errors.updateFailed')); }, }); const deleteMutation = useMutation({ mutationFn: rbacApi.deleteRole, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-roles'] }); setDeleteConfirm(null); }, onError: () => { setDeleteConfirm(null); setFormError(t('admin.roles.errors.deleteFailed')); }, }); // Handlers const closeModal = useCallback(() => { setModalOpen(false); setEditingRole(null); setFormData(INITIAL_FORM); setFormError(null); }, []); const openCreateModal = useCallback(() => { setEditingRole(null); setFormData(INITIAL_FORM); setFormError(null); setModalOpen(true); }, []); const openEditModal = useCallback((role: AdminRole) => { setEditingRole(role); setFormData({ name: role.name, description: role.description || '', level: role.level, color: role.color || ROLE_COLORS[0], permissions: [...role.permissions], }); setFormError(null); setModalOpen(true); }, []); const handleTogglePermission = useCallback((perm: string) => { setFormData((prev) => { const has = prev.permissions.includes(perm); return { ...prev, permissions: has ? prev.permissions.filter((p) => p !== perm) : [...prev.permissions, perm], }; }); }, []); const handleToggleSection = useCallback((section: string, actions: string[]) => { setFormData((prev) => { const allPerms = actions.map((a) => `${section}:${a}`); const allSelected = allPerms.every( (p) => prev.permissions.includes(p) || prev.permissions.includes(`${section}:*`), ); if (allSelected) { // Deselect all in this section (both individual and wildcard) const sectionPerms = new Set([...allPerms, `${section}:*`]); return { ...prev, permissions: prev.permissions.filter((p) => !sectionPerms.has(p)), }; } // Select all: use wildcard const withoutSection = prev.permissions.filter((p) => !p.startsWith(`${section}:`)); return { ...prev, permissions: [...withoutSection, `${section}:*`], }; }); }, []); const handleApplyPreset = useCallback((presetKey: string) => { const presetPerms = PRESETS[presetKey]; if (!presetPerms) return; setFormData((prev) => ({ ...prev, permissions: [...presetPerms], })); }, []); const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); setFormError(null); if (!formData.name.trim()) { setFormError(t('admin.roles.errors.nameRequired')); return; } const payload = { name: formData.name.trim(), description: formData.description.trim() || null, level: formData.level, permissions: formData.permissions, color: formData.color, }; if (editingRole) { updateMutation.mutate({ id: editingRole.id, payload }); } else { createMutation.mutate(payload); } }, [formData, editingRole, createMutation, updateMutation, t], ); const isSaving = createMutation.isPending || updateMutation.isPending; // Sorted roles by level descending const sortedRoles = useMemo(() => { if (!roles) return []; return [...roles].sort((a, b) => b.level - a.level); }, [roles]); return ( {/* Header */} {!capabilities.hasBackButton && ( navigate('/admin')} className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600" > )} {t('admin.roles.title')} {t('admin.roles.subtitle')} {t('admin.roles.createRole')} {/* Stats Overview */} {sortedRoles.length > 0 && ( {sortedRoles.length} {t('admin.roles.stats.totalRoles')} {sortedRoles.filter((r) => r.is_active).length} {t('admin.roles.stats.active')} {sortedRoles.filter((r) => r.is_system).length} {t('admin.roles.stats.system')} )} {/* Roles List */} {rolesLoading ? ( ) : rolesError ? ( {t('admin.roles.errors.loadFailed')} ) : sortedRoles.length === 0 ? ( {t('admin.roles.noRoles')} ) : ( {sortedRoles.map((role) => ( {/* Role name with color badge */} {role.name} {role.is_system && ( {t('admin.roles.systemBadge')} )} {!role.is_active && ( {t('admin.roles.inactiveBadge')} )} {/* Info */} {t('admin.roles.levelLabel')}: {role.level} {role.description && {role.description}} {t('admin.roles.usersCount', { count: role.user_count ?? 0 })} {t('admin.roles.permissionsCount', { count: role.permissions.length, })} {/* Actions */} openEditModal(role)} disabled={role.is_system || !canManageRole(role.level)} className="flex-1 rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40 sm:flex-none" title={t('admin.roles.actions.edit')} > setDeleteConfirm(role.id)} disabled={role.is_system || !canManageRole(role.level)} className="flex-1 rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:cursor-not-allowed disabled:opacity-40 sm:flex-none" title={t('admin.roles.actions.delete')} > ))} )} {/* Create / Edit Modal */} {modalOpen && ( {/* Backdrop */} {/* Modal content */} {/* Modal header */} {editingRole ? t('admin.roles.modal.editTitle') : t('admin.roles.modal.createTitle')} {/* Name */} {t('admin.roles.form.name')} setFormData((prev) => ({ ...prev, name: e.target.value }))} className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500" placeholder={t('admin.roles.form.namePlaceholder')} autoFocus /> {/* Description */} {t('admin.roles.form.description')} setFormData((prev) => ({ ...prev, description: e.target.value })) } className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500" placeholder={t('admin.roles.form.descriptionPlaceholder')} rows={2} /> {/* Level */} {t('admin.roles.form.level')} setFormData((prev) => ({ ...prev, level: Number(e.target.value), })) } className="flex-1 accent-accent-500" /> setFormData((prev) => ({ ...prev, level: Math.min(999, Math.max(0, Number(e.target.value) || 0)), })) } className="w-20 rounded-lg border border-dark-600 bg-dark-900 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500" aria-label={t('admin.roles.form.levelValue')} /> {t('admin.roles.form.levelHint')} {/* Color picker */} {t('admin.roles.form.color')} {ROLE_COLORS.map((color) => ( setFormData((prev) => ({ ...prev, color }))} className={`h-7 w-7 rounded-full border-2 transition-transform hover:scale-110 ${ formData.color === color ? 'scale-110 border-white' : 'border-transparent' }`} style={{ backgroundColor: color }} aria-label={color} /> ))} {/* Preset buttons */} {t('admin.roles.form.presets')} {Object.keys(PRESETS).map((key) => ( handleApplyPreset(key)} className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-xs font-medium text-dark-300 transition-colors hover:border-accent-500/50 hover:bg-accent-500/10 hover:text-accent-400" > {t(`admin.roles.presets.${key}`)} ))} {/* Permission Matrix */} {permissionRegistry && permissionRegistry.length > 0 && ( )} {/* Selected count */} {t('admin.roles.form.selectedPermissions', { count: formData.permissions.length, })} {/* Error */} {formError && {formError}} {/* Actions */} {t('admin.roles.form.cancel')} {isSaving ? t('admin.roles.form.saving') : t('admin.roles.form.save')} )} {/* Delete Confirmation */} {deleteConfirm !== null && ( setDeleteConfirm(null)} aria-hidden="true" /> {t('admin.roles.confirm.title')} {t('admin.roles.confirm.text')} setDeleteConfirm(null)} className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100" > {t('admin.roles.confirm.cancel')} deleteMutation.mutate(deleteConfirm)} disabled={deleteMutation.isPending} className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50" > {deleteMutation.isPending ? t('admin.roles.confirm.deleting') : t('admin.roles.confirm.delete')} )} ); }
{t('admin.roles.subtitle')}
{t('admin.roles.errors.loadFailed')}
{t('admin.roles.noRoles')}
{t('admin.roles.form.levelHint')}
{t('admin.roles.form.selectedPermissions', { count: formData.permissions.length, })}
{formError}
{t('admin.roles.confirm.text')}