feature/rbac y perfiles implementados

This commit is contained in:
2026-05-21 08:00:43 -06:00
parent 546a411df8
commit dc5f9fd6ce
29 changed files with 2007 additions and 977 deletions

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { createUser, updateUser } from '../api/users.ts';
import { fetchRoles, fetchUserRoles, assignUserRole, revokeUserRole } from '../api/rbac';
import { useNotification } from '../context/NotificationContext';
const initialForm = {
@@ -12,38 +13,31 @@ const initialForm = {
confirmPassword: '',
rfc: [],
userType: 'agente', // 'agente' | 'importador'
groups: [],
is_active: true,
};
// Perfiles disponibles en el sistema
const AVAILABLE_GROUPS = [
{ id: 1, label: 'Admin', description: 'Administrador del sistema' },
{ id: 2, label: 'Developer', description: 'Desarrollador' },
{ id: 3, label: 'User', description: 'Acceso base (requerido)' },
{ id: 4, label: 'Agente Aduanal', description: 'Agente aduanal' },
{ id: 5, label: 'Importador', description: 'Importador general' }
];
export default function UserForm() {
const { id } = useParams(); // presente si es edición
const { id } = useParams();
const isEditing = Boolean(id);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { showMessage } = useNotification();
// Preseleccionar tipo desde query param (?type=agente|importador)
const initialType = searchParams.get('type') === 'importador' ? 'importador' : 'agente';
const [form, setForm] = useState({
...initialForm,
userType: initialType,
groups: initialType === 'importador' ? [3, 5] : [4, 3],
});
const [form, setForm] = useState({ ...initialForm, userType: initialType });
const [importadores, setImportadores] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [loadingUser, setLoadingUser] = useState(isEditing);
// RBAC: roles disponibles de la organización
const [availableRoles, setAvailableRoles] = useState([]);
const [loadingRoles, setLoadingRoles] = useState(true);
// IDs de roles seleccionados en el formulario
const [selectedRoleIds, setSelectedRoleIds] = useState([]);
// Asignaciones actuales del usuario (para calcular diff en edición): [{ id, role }]
const [currentUserRoles, setCurrentUserRoles] = useState([]);
// Validación de contraseña
const [passwordValidation, setPasswordValidation] = useState({
length: false, uppercase: false, lowercase: false, number: false, special: false,
@@ -75,6 +69,24 @@ export default function UserForm() {
}
}, []);
// Cargar roles disponibles de la organización
useEffect(() => {
fetchRoles()
.then(data => {
const list = Array.isArray(data) ? data : (data?.results ?? []);
setAvailableRoles(list);
// Preselección por tipo inicial solo en creación
if (!isEditing) {
setSelectedRoleIds(getDefaultRoleIds(list, initialType));
}
})
.catch(err => {
showMessage(err.message || 'Error al cargar los perfiles disponibles', 'error');
setAvailableRoles([]);
})
.finally(() => setLoadingRoles(false));
}, []);
// Cargar importadores
useEffect(() => {
const access = localStorage.getItem('access');
@@ -82,22 +94,30 @@ export default function UserForm() {
fetch(`${import.meta.env.VITE_EFC_API_URL}/customs/importadores/`, {
headers: { Authorization: `Bearer ${access}` },
})
.then(r => r.json())
.then(r => {
if (!r.ok) throw new Error(`Error ${r.status} al cargar importadores`);
return r.json();
})
.then(data => setImportadores(Array.isArray(data) ? data : []))
.catch(() => setImportadores([]));
.catch(err => {
showMessage(err.message || 'Error al cargar el catálogo de importadores', 'error');
setImportadores([]);
});
}, []);
// Cargar datos del usuario si es edición
useEffect(() => {
if (!isEditing) return;
const access = localStorage.getItem('access');
fetch(`${import.meta.env.VITE_EFC_API_URL}/user/users/${id}/`, {
headers: { Authorization: `Bearer ${access}` },
})
.then(r => r.json())
.then(data => {
const isImportador = data.is_importador === true ||
(Array.isArray(data.groups) && data.groups.includes(5));
Promise.all([
fetch(`${import.meta.env.VITE_EFC_API_URL}/user/users/${id}/`, {
headers: { Authorization: `Bearer ${access}` },
}).then(r => r.json()),
fetchUserRoles(id).catch(() => []),
])
.then(([data, userRoles]) => {
const isImportador = data.is_importador === true;
setForm({
username: data.username || '',
email: data.email || '',
@@ -105,12 +125,21 @@ export default function UserForm() {
last_name: data.last_name || '',
password: '',
confirmPassword: '',
// rfc es M2M: viene como array de PKs (strings de RFC)
rfc: Array.isArray(data.rfc) ? data.rfc : (data.rfc ? [data.rfc] : []),
userType: isImportador ? 'importador' : 'agente',
groups: Array.isArray(data.groups) ? data.groups : [],
is_active: data.is_active !== false,
});
// user-roles respuesta: [{ id, user: {...}, role: { id, nombre, ... }, created_at }]
const normalized = (Array.isArray(userRoles) ? userRoles : (userRoles?.results ?? []))
.map(ur => ({
id: ur.id,
role: typeof ur.role === 'object' ? ur.role?.id : ur.role,
}))
.filter(ur => ur.role);
setCurrentUserRoles(normalized);
setSelectedRoleIds(normalized.map(ur => ur.role));
setLoadingUser(false);
})
.catch(() => {
@@ -119,6 +148,15 @@ export default function UserForm() {
});
}, [id, isEditing, showMessage]);
// Preseleccionar roles por defecto según tipo de usuario
function getDefaultRoleIds(roles, type) {
const keyword = type === 'importador' ? 'importador' : 'agente';
const matches = roles
.filter(r => r.nombre?.toLowerCase().includes(keyword))
.map(r => r.id);
return matches;
}
const validatePassword = (password) => {
const v = {
length: password.length >= 8,
@@ -143,7 +181,6 @@ export default function UserForm() {
return isPasswordValid() && passwordsMatch &&
form.password.length > 0 && form.confirmPassword.length > 0;
}
// En edición la contraseña es opcional
if (form.password.length > 0) {
return isPasswordValid() && passwordsMatch && form.confirmPassword.length > 0;
}
@@ -164,20 +201,18 @@ export default function UserForm() {
setForm(prev => ({
...prev,
userType: type,
// Limpiar RFCs si cambia a agente
rfc: type === 'agente' ? [] : prev.rfc,
// Preseleccionar perfiles por defecto según tipo
groups: type === 'importador' ? [3, 5] : [4, 3],
}));
// Preseleccionar roles según tipo
setSelectedRoleIds(getDefaultRoleIds(availableRoles, type));
};
const handleGroupToggle = (groupId) => {
setForm(prev => {
const groups = prev.groups.includes(groupId)
? prev.groups.filter(g => g !== groupId)
: [...prev.groups, groupId];
return { ...prev, groups };
});
const handleRoleToggle = (roleId) => {
setSelectedRoleIds(prev =>
prev.includes(roleId)
? prev.filter(id => id !== roleId)
: [...prev, roleId]
);
};
const handleRfcToggle = (rfc) => {
@@ -190,6 +225,18 @@ export default function UserForm() {
});
};
// Sincronizar roles: calcular diff y llamar assign/revoke
const syncRoles = async (userId) => {
const currentRoleIds = currentUserRoles.map(ur => ur.role);
const toAdd = selectedRoleIds.filter(id => !currentRoleIds.includes(id));
const toRemove = currentUserRoles.filter(ur => !selectedRoleIds.includes(ur.role));
await Promise.all([
...toAdd.map(roleId => assignUserRole(userId, roleId).catch(() => null)),
...toRemove.map(ur => revokeUserRole(ur.id).catch(() => null)),
]);
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!isFormValid()) return;
@@ -200,7 +247,6 @@ export default function UserForm() {
email: form.email,
first_name: form.first_name,
last_name: form.last_name,
groups: form.groups,
is_importador: form.userType === 'importador',
is_active: form.is_active,
};
@@ -211,9 +257,16 @@ export default function UserForm() {
if (isEditing) {
await updateUser(id, payload);
await syncRoles(id);
showMessage('Usuario actualizado exitosamente', 'success');
} else {
await createUser(payload);
const newUser = await createUser(payload);
const newUserId = newUser?.id;
if (newUserId && selectedRoleIds.length > 0) {
await Promise.all(
selectedRoleIds.map(roleId => assignUserRole(newUserId, roleId).catch(() => null))
);
}
showMessage('Usuario creado exitosamente', 'success');
}
navigate('/users');
@@ -229,7 +282,7 @@ export default function UserForm() {
if (loadingUser) {
return (
<div className="min-h-screen p-4 sm:p-6 bg-gradient-to-br from-gray-50 to-blue-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
);
}
@@ -253,7 +306,6 @@ export default function UserForm() {
{isEditing ? 'Modifica los datos del usuario seleccionado' : 'Registro en el Sistema de Gestión de Usuarios'}
</p>
</div>
{/* Botón regresar */}
<button
type="button"
onClick={() => navigate('/users')}
@@ -271,7 +323,6 @@ export default function UserForm() {
</div>
</div>
{/* Formulario */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Tipo de usuario — solo en creación */}
@@ -426,7 +477,6 @@ export default function UserForm() {
<p className="text-sm text-gray-500 italic">No hay importadores disponibles en el catálogo.</p>
) : (
<div className="flex flex-col sm:flex-row gap-3 items-stretch">
{/* Columna izquierda — disponibles */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center justify-between mb-1.5">
@@ -518,54 +568,91 @@ export default function UserForm() {
</div>
</div>
</div>
</div>
)}
</div>
)}
{/* Perfiles */}
{/* Perfiles RBAC */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 fade-in-up-users" style={{ animationDelay: '0.15s' }}>
<div className="flex items-center mb-4 pb-3 border-b border-gray-200">
<div className="bg-blue-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-200">
<div className="flex items-center">
<div className="bg-indigo-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Perfiles</h4>
<p className="text-xs text-slate-500">
Asigna los perfiles que tendrá este usuario en la organización
</p>
</div>
</div>
{selectedRoleIds.length > 0 && (
<span className="text-xs text-indigo-600 font-semibold bg-indigo-50 px-2 py-0.5 rounded-full border border-indigo-200">
{selectedRoleIds.length} seleccionado{selectedRoleIds.length !== 1 ? 's' : ''}
</span>
)}
</div>
{loadingRoles ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600" />
</div>
) : availableRoles.length === 0 ? (
<div className="text-center py-8 text-slate-400">
<svg className="w-8 h-8 mx-auto mb-2 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<p className="text-xs">No hay perfiles disponibles en la organización</p>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Perfiles</h4>
<p className="text-xs text-slate-500">Asigna los perfiles a los que pertenecerá el usuario</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{AVAILABLE_GROUPS.map(group => {
const active = form.groups.includes(group.id);
return (
<button
key={group.id}
type="button"
onClick={() => handleGroupToggle(group.id)}
className={`relative flex flex-col items-start p-3 rounded-xl border-2 text-left transition-all duration-200 ${
active
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'
}`}
>
<span className={`text-xs font-semibold ${active ? 'text-blue-800' : 'text-gray-700'}`}>
{group.label}
</span>
<span className="text-xs text-gray-400 mt-0.5">{group.description}</span>
{active && (
<div className="absolute top-2 right-2 w-4 h-4 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{availableRoles.map(role => {
const active = selectedRoleIds.includes(role.id);
return (
<button
key={role.id}
type="button"
onClick={() => handleRoleToggle(role.id)}
className={`relative flex flex-col items-start p-3 rounded-xl border-2 text-left transition-all duration-200 ${
active
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 bg-white hover:border-indigo-300 hover:bg-indigo-50/40'
}`}
>
<div className="flex items-center gap-2 w-full">
<div className={`w-6 h-6 rounded-md flex items-center justify-center flex-shrink-0 ${active ? 'bg-indigo-600' : 'bg-gray-100'}`}>
{role.is_admin_role ? (
<svg className={`w-3.5 h-3.5 ${active ? 'text-white' : 'text-amber-500'}`} fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l2.09 6.26L20 10l-5 4.87L16.18 22 12 18.77 7.82 22 9 14.87 4 10l5.91-1.74z" />
</svg>
) : (
<svg className={`w-3.5 h-3.5 ${active ? 'text-white' : 'text-gray-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
)}
</div>
<span className={`text-xs font-semibold truncate flex-1 ${active ? 'text-indigo-800' : 'text-gray-700'}`}>
{role.nombre}
</span>
</div>
)}
</button>
);
})}
</div>
{role.is_admin_role && (
<span className="mt-1.5 text-xs text-amber-600 font-medium">Administrador</span>
)}
{active && (
<div className="absolute top-2 right-2 w-4 h-4 bg-indigo-600 rounded-full flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</button>
);
})}
</div>
)}
</div>
{/* Credenciales */}
@@ -585,7 +672,7 @@ export default function UserForm() {
</div>
<div className="space-y-4">
{/* Estado del usuario */}
{/* Estado */}
<div className="flex items-center justify-between p-3 rounded-xl border border-slate-200 bg-slate-50">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${form.is_active ? 'bg-blue-100' : 'bg-slate-200'}`}>
@@ -649,8 +736,6 @@ export default function UserForm() {
)}
</button>
</div>
{/* Indicadores de validación */}
{showPasswordValidation && (
<div className="mt-3 p-3 bg-slate-100 rounded-lg border">
<div className="flex items-center justify-between mb-2">
@@ -736,7 +821,7 @@ export default function UserForm() {
</div>
</div>
{/* Botones de acción */}
{/* Botones */}
<div className="flex flex-col sm:flex-row justify-end gap-3 pb-8 fade-in-up-users" style={{ animationDelay: '0.25s' }}>
<button
type="button"
@@ -751,7 +836,7 @@ export default function UserForm() {
disabled={submitting || (showPasswordValidation && !isFormValid())}
className="w-full sm:w-auto px-6 py-2.5 border border-transparent rounded-lg shadow-lg text-sm font-semibold text-white bg-gradient-to-r from-blue-700 to-blue-900 hover:from-blue-800 hover:to-blue-950 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>}
{submitting && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={isEditing ? 'M5 13l4 4L19 7' : 'M12 6v6m0 0v6m0-6h6m-6 0H6'} />
</svg>