Files
dh-inspeccion-v2/web-v2/src/pages/UserDetailPage.tsx
T

176 lines
12 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useLocation, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
listRoles,
replaceUserRoles,
resetUserPassword,
updateUserStatus,
} from '../lib/api';
import type { AdministrativeRole } from '../lib/api';
import {
getUserProfile,
updateUserProfile,
} from '../lib/userProfileApi';
import type { AdministrativeUserProfile } from '../lib/userProfileApi';
import { formatDate, initials } from '../lib/format';
export function UserDetailPage() {
const { id = '' } = useParams();
const location = useLocation();
const { user: currentUser, hasPermission } = useAuth();
const [user, setUser] = useState<AdministrativeUserProfile | null>(null);
const [roles, setRoles] = useState<AdministrativeRole[]>([]);
const [roleIds, setRoleIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState((location.state as { created?: boolean } | null)?.created ? 'Usuario creado correctamente' : '');
const [resetPasswordValue, setResetPasswordValue] = useState('');
const [resetPasswordConfirm, setResetPasswordConfirm] = useState('');
const [forcePasswordChange, setForcePasswordChange] = useState(true);
const load = async () => {
setLoading(true);
try {
const [loadedUser, loadedRoles] = await Promise.all([getUserProfile(id), listRoles()]);
setUser(loadedUser);
setRoles(loadedRoles);
setRoleIds(loadedUser.roles.map((role) => role.id));
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setLoading(false);
}
};
useEffect(() => { void load(); }, [id]);
const inspectorRoleId = useMemo(() => roles.find((role) => role.code === 'inspector')?.id ?? null, [roles]);
const inspectorSelected = inspectorRoleId ? roleIds.includes(inspectorRoleId) : false;
const saveProfile = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(''); setSuccess('');
const data = new FormData(event.currentTarget);
const email = String(data.get('email') ?? '').trim();
if (inspectorSelected && !email) {
setError('El email es obligatorio para un Inspector porque allí recibe la documentación de sus inspecciones.');
return;
}
setSaving('profile');
try {
const updated = await updateUserProfile(id, {
firstName: String(data.get('firstName')),
lastName: String(data.get('lastName')),
username: String(data.get('username')),
email: email || null,
dni: String(data.get('dni') ?? '').trim() || null,
phone: String(data.get('phone') ?? '').trim() || null,
jobTitle: String(data.get('jobTitle') ?? '').trim() || null,
employeeNumber: String(data.get('employeeNumber') ?? '').trim() || null,
});
setUser(updated); setSuccess('Datos personales y de contacto actualizados');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(''); }
};
const saveRoles = async () => {
if (inspectorSelected && !user?.email) {
setError('Antes de asignar el rol Inspector, cargá y guardá un email válido.');
return;
}
setError(''); setSuccess(''); setSaving('roles');
try {
await replaceUserRoles(id, roleIds);
const refreshed = await getUserProfile(id);
setUser(refreshed); setSuccess('Roles actualizados correctamente');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(''); }
};
const changeStatus = async () => {
if (!user) return;
const next = user.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
const verb = next === 'INACTIVE' ? 'desactivar' : 'activar';
if (!window.confirm(`¿Confirmás que querés ${verb} a ${user.username}?`)) return;
setError(''); setSuccess(''); setSaving('status');
try {
await updateUserStatus(id, next);
const refreshed = await getUserProfile(id);
setUser(refreshed); setSuccess(`Usuario ${next === 'ACTIVE' ? 'activado' : 'desactivado'}`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(''); }
};
const resetPassword = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (resetPasswordValue.length < 12) { setError('La contraseña temporal debe tener al menos 12 caracteres.'); return; }
if (resetPasswordValue !== resetPasswordConfirm) { setError('Las contraseñas no coinciden.'); return; }
if (!window.confirm(`¿Restablecer la contraseña de ${user?.username ?? 'este usuario'}? Se cerrarán todas sus sesiones activas.`)) return;
setError(''); setSuccess(''); setSaving('password');
try {
await resetUserPassword(id, resetPasswordValue, forcePasswordChange);
const refreshed = await getUserProfile(id);
setUser(refreshed);
setResetPasswordValue(''); setResetPasswordConfirm('');
setSuccess('Contraseña restablecida. Las sesiones anteriores fueron revocadas.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(''); }
};
const toggleRole = (roleId: string) => setRoleIds((current) => current.includes(roleId) ? current.filter((value) => value !== roleId) : [...current, roleId]);
if (loading) return <LoadingBlock label="Cargando usuario…" />;
if (!user) return <Alert>{error || 'Usuario no encontrado'}</Alert>;
const canUpdate = hasPermission('users.update');
const canAssign = hasPermission('users.assign_roles');
const canChangeStatus = hasPermission('users.change_status');
return <section>
<div className="breadcrumb"><Link to="/admin/users">Usuarios</Link><span>/</span><strong>{user.username}</strong></div>
<div className="page-heading user-heading"><div className="profile-title"><span className="profile-avatar">{initials(user.firstName, user.lastName)}</span><div><span className="eyebrow">DETALLE DE USUARIO</span><h1>{user.firstName} {user.lastName}</h1><p>@{user.username} · {user.jobTitle || 'Función sin informar'} · Creado {formatDate(user.createdAt)}</p></div></div>{canChangeStatus && <button className={`button ${user.status === 'ACTIVE' ? 'danger-outline' : 'success-outline'}`} disabled={saving === 'status' || currentUser?.id === user.id} onClick={changeStatus}>{user.status === 'ACTIVE' ? 'Desactivar acceso' : 'Activar acceso'}</button>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="detail-grid">
<form className="panel form-panel" onSubmit={saveProfile}>
<div className="panel-heading"><div><span className="eyebrow">PERFIL</span><h2>Datos personales y contacto</h2></div><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span></div>
<div className="form-grid">
<label className="field"><span>Nombre</span><input name="firstName" defaultValue={user.firstName} required disabled={!canUpdate} /></label>
<label className="field"><span>Apellido</span><input name="lastName" defaultValue={user.lastName} required disabled={!canUpdate} /></label>
<label className="field"><span>DNI</span><input name="dni" defaultValue={user.dni ?? ''} inputMode="numeric" maxLength={15} disabled={!canUpdate} /></label>
<label className="field"><span>Teléfono</span><input name="phone" type="tel" defaultValue={user.phone ?? ''} maxLength={40} disabled={!canUpdate} /></label>
<label className="field"><span>Cargo / función</span><input name="jobTitle" defaultValue={user.jobTitle ?? ''} maxLength={160} disabled={!canUpdate} /></label>
<label className="field"><span>Legajo / matrícula</span><input name="employeeNumber" defaultValue={user.employeeNumber ?? ''} maxLength={80} disabled={!canUpdate} /></label>
<label className="field"><span>Usuario</span><input name="username" defaultValue={user.username} required disabled={!canUpdate} /></label>
<label className="field"><span>Email {inspectorSelected && <em>obligatorio para Inspector</em>}</span><input name="email" type="email" defaultValue={user.email ?? ''} required={inspectorSelected} disabled={!canUpdate} /></label>
</div>
{inspectorSelected && <div className="temporal-notice"><Icon name="mail" /><p><strong>Destinatario del Inspector:</strong> al finalizar una inspección, la documentación se enviará también a <strong>{user.email || 'este email cuando lo completes'}</strong>.</p></div>}
<div className="metadata-grid"><div><small>Último acceso</small><strong>{formatDate(user.lastLoginAt)}</strong></div><div><small>Último cambio de clave</small><strong>{formatDate(user.passwordChangedAt)}</strong></div><div><small>Intentos fallidos</small><strong>{user.failedLoginAttempts}</strong></div><div><small>Bloqueado hasta</small><strong>{formatDate(user.lockedUntil)}</strong></div></div>
{user.mustChangePassword && <Alert type="info">Este usuario debe cambiar su contraseña temporal en el próximo ingreso.</Alert>}
{canUpdate && <div className="form-actions"><button className="button primary" disabled={saving === 'profile'}><Icon name="edit" />{saving === 'profile' ? 'Guardando…' : 'Guardar datos'}</button></div>}
</form>
{canUpdate && <form className="panel form-panel" onSubmit={resetPassword}>
<div className="panel-heading"><div><span className="eyebrow">SEGURIDAD</span><h2>Restablecer contraseña</h2></div><Icon name="key" /></div>
<Alert type="info">El restablecimiento revoca todas las sesiones activas del usuario. La contraseña nunca se guarda en auditoría.</Alert>
<label className="field"><span>Nueva contraseña temporal</span><input type="password" value={resetPasswordValue} onChange={(event) => setResetPasswordValue(event.target.value)} minLength={12} maxLength={128} autoComplete="new-password" required /><small>Mínimo 12 caracteres.</small></label>
<label className="field"><span>Repetir contraseña</span><input type="password" value={resetPasswordConfirm} onChange={(event) => setResetPasswordConfirm(event.target.value)} minLength={12} maxLength={128} autoComplete="new-password" required /></label>
<label className="check-row"><input type="checkbox" checked={forcePasswordChange} onChange={(event) => setForcePasswordChange(event.target.checked)} /><span><strong>Exigir cambio en el próximo ingreso</strong><small>Recomendado para claves temporales entregadas por administración.</small></span></label>
<div className="form-actions"><button className="button primary" disabled={saving === 'password'}>{saving === 'password' ? 'Restableciendo…' : 'Restablecer contraseña'}</button></div>
</form>}
<article className="panel form-panel">
<div className="panel-heading"><div><span className="eyebrow">AUTORIZACIÓN</span><h2>Roles asignados</h2></div><span className="count-pill">{roleIds.length}</span></div>
<div className="choice-list">{roles.map((role) => <label className={`choice-card compact ${roleIds.includes(role.id) ? 'selected' : ''}`} key={role.id}><input type="checkbox" checked={roleIds.includes(role.id)} onChange={() => toggleRole(role.id)} disabled={!canAssign} /><span><strong>{role.name}</strong><small>{role.description}</small></span><Icon name="check" /></label>)}</div>
{inspectorSelected && !user.email && <Alert>Para guardar el rol Inspector, primero cargá y guardá un email válido.</Alert>}
{canAssign && <div className="form-actions"><button className="button primary" type="button" onClick={saveRoles} disabled={saving === 'roles' || (inspectorSelected && !user.email)}>{saving === 'roles' ? 'Guardando…' : 'Guardar roles'}</button></div>}
</article>
</div>
</section>;
}