feature/rbac y perfiles implementados
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user