chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { useEffect, 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 {
|
||||
getUser,
|
||||
listRoles,
|
||||
replaceUserRoles,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
updateUserStatus,
|
||||
} from '../lib/api';
|
||||
import type { AdministrativeRole, AdministrativeUser } from '../lib/api';
|
||||
import { formatDate, initials } from '../lib/format';
|
||||
|
||||
export function UserDetailPage() {
|
||||
const { id = '' } = useParams();
|
||||
const location = useLocation();
|
||||
const { user: currentUser, hasPermission } = useAuth();
|
||||
const [user, setUser] = useState<AdministrativeUser | 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([getUser(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 saveProfile = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(''); setSuccess(''); setSaving('profile');
|
||||
const data = new FormData(event.currentTarget);
|
||||
try {
|
||||
const updated = await updateUser(id, {
|
||||
firstName: String(data.get('firstName')),
|
||||
lastName: String(data.get('lastName')),
|
||||
username: String(data.get('username')),
|
||||
email: String(data.get('email') ?? '') || null,
|
||||
});
|
||||
setUser(updated); setSuccess('Datos del usuario actualizados');
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(''); }
|
||||
};
|
||||
|
||||
const saveRoles = async () => {
|
||||
setError(''); setSuccess(''); setSaving('roles');
|
||||
try {
|
||||
const updated = await replaceUserRoles(id, roleIds);
|
||||
setUser(updated); 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 {
|
||||
const updated = await updateUserStatus(id, next);
|
||||
setUser(updated); 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 {
|
||||
const updated = await resetUserPassword(id, resetPasswordValue, forcePasswordChange);
|
||||
setUser(updated);
|
||||
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} · 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">CUENTA</span><h2>Datos personales</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>Usuario</span><input name="username" defaultValue={user.username} required disabled={!canUpdate} /></label><label className="field"><span>Email</span><input name="email" type="email" defaultValue={user.email ?? ''} disabled={!canUpdate} /></label></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>
|
||||
{canAssign && <div className="form-actions"><button className="button primary" type="button" onClick={saveRoles} disabled={saving === 'roles'}>{saving === 'roles' ? 'Guardando…' : 'Guardar roles'}</button></div>}
|
||||
</article>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
Reference in New Issue
Block a user