feat: autorregistro con verificacion de correo + aprobacion, superadmin y SMTP
- POST /api/auth/register: primer usuario del sistema -> superadmin con sesion directa (bootstrap, sin correo ni aprobacion); los siguientes quedan pending_verification y reciben un correo con enlace de un solo uso (24h). - GET /api/auth/verify-email: pasa a pending_approval y notifica in-app a los admin/superadmin activos. - admin/superadmin aprueban (POST /api/users/:id/approve, eligen rol y departamento) o rechazan (POST /api/users/:id/reject, borra y audita el email) desde Organizacion -> Usuarios (nuevo badge de estado + acciones). - login() da mensaje especifico segun el status de la cuenta en vez del generico "credenciales invalidas". - Recuperacion de clave (forgot/reset-password): respuesta siempre generica, token de 1 hora de un solo uso. - Nuevo modulo settings: SMTP configurable SOLO por superadmin desde Configuracion -> Correo (SMTP); clave cifrada AES-256-GCM (ENCRYPTION_KEY) en la tabla system_settings, nunca expuesta por la API. sendMail() degrada a modo dev (loguea en vez de enviar) si no hay SMTP configurado, y nunca deja que un fallo de envio tumbe al que lo llama. - Migracion 002_registration_and_smtp.sql: rol superadmin, columnas de ciclo de vida en users (status, tokens), tabla system_settings, y promueve al usuario mas antiguo del sistema a superadmin. - Fix: React.StrictMode disparaba el efecto de VerifyEmailPage dos veces; la segunda llamada (token ya consumido) mostraba "enlace invalido" pese a que la verificacion ya habia funcionado. Se agrega una guarda por token. - Fix (deuda tecnica v1.0.0): se reordena el montaje de rutas en app.js — subtasksRoutes (mount generico en /api) va al final, para no interceptar con su requireAuth interno una request sin token dirigida a un router mas especifico registrado despues. - Documentacion actualizada: AGENTS.md, SPEC.md, API.md, BASE_DE_DATOS.md, CONFIGURACION.md, ESTADO.md, CHANGELOG.md. Version 1.1.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Save, UserX, UserCheck } from 'lucide-react';
|
||||
import { Plus, Save, UserX, UserCheck, Check, X as XIcon } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { listUsers, listRoles, createUser, updateUser, setUserActive } from '../../services/users.js';
|
||||
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser } from '../../services/users.js';
|
||||
import { listDepartments } from '../../services/departments.js';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||
@@ -17,19 +18,25 @@ function flatten(nodes, acc = []) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const STATUS_COLOR = { pending_verification: 'muted', pending_approval: 'warning', active: 'success' };
|
||||
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '' };
|
||||
|
||||
export default function UsersPanel() {
|
||||
const { t, pushToast } = useUi();
|
||||
const { t, pushToast, openDialog } = useUi();
|
||||
const { user: currentUser } = useAuth();
|
||||
const runAction = useAction();
|
||||
const { data: users, reload } = useAsync(listUsers, []);
|
||||
const { data: roles } = useAsync(listRoles, []);
|
||||
const { data: deptTree } = useAsync(listDepartments, []);
|
||||
|
||||
const canManage = ['admin', 'superadmin'].includes(currentUser?.roleCode);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [approving, setApproving] = useState(null);
|
||||
const [approveForm, setApproveForm] = useState({ roleId: '', departmentId: '' });
|
||||
|
||||
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
|
||||
const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name }));
|
||||
@@ -41,6 +48,7 @@ export default function UsersPanel() {
|
||||
}
|
||||
|
||||
function startEdit(user) {
|
||||
if (!canManage) return;
|
||||
setSelected(user);
|
||||
setForm({ name: user.name, email: user.email, roleId: user.role_id, departmentId: user.department_id || '', position: user.position || '', password: '' });
|
||||
setEditing(true);
|
||||
@@ -68,13 +76,40 @@ export default function UsersPanel() {
|
||||
});
|
||||
}
|
||||
|
||||
function startApprove(user) {
|
||||
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
|
||||
setApproving(user);
|
||||
setApproveForm({ roleId: user.role_id || collaboratorRole?.id || '', departmentId: '' });
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
await runAction(async () => {
|
||||
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null });
|
||||
setApproving(null);
|
||||
await reload();
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
}
|
||||
|
||||
function confirmReject(user) {
|
||||
openDialog({
|
||||
tone: 'warning',
|
||||
title: t('user_reject'),
|
||||
message: t('user_reject_confirm_msg'),
|
||||
actions: [
|
||||
{ label: t('common_cancel'), variant: 'standard' },
|
||||
{ label: t('user_reject'), variant: 'danger', onClick: () => runAction(async () => { await rejectUser(user.id); await reload(); }) },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: t('common_name') },
|
||||
{ key: 'email', label: t('user_email') },
|
||||
{ key: 'role_name', label: t('user_role') },
|
||||
{ key: 'department_name', label: t('common_department'), render: (r) => r.department_name || '—' },
|
||||
{ key: 'is_active', label: t('common_status'), sortable: false, render: (r) => (
|
||||
<Badge color={r.is_active ? 'success' : 'muted'}>{r.is_active ? t('common_active') : t('common_inactive')}</Badge>
|
||||
{ key: 'status', label: t('common_status'), sortable: false, render: (r) => (
|
||||
<Badge color={STATUS_COLOR[r.status] || 'muted'}>{t(`user_status_${r.status}`)}</Badge>
|
||||
) },
|
||||
];
|
||||
|
||||
@@ -86,7 +121,7 @@ export default function UsersPanel() {
|
||||
<div className="toolbar-left">
|
||||
<SearchInput value={search} onChange={setSearch} />
|
||||
</div>
|
||||
<Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}</Button>
|
||||
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}</Button>}
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
@@ -95,13 +130,20 @@ export default function UsersPanel() {
|
||||
searchValue={search}
|
||||
getSearchHaystack={(u) => `${u.name} ${u.email} ${u.role_name} ${u.department_name || ''}`}
|
||||
getRowKey={(u) => u.id}
|
||||
onRowClick={startEdit}
|
||||
rowActions={(u) => [
|
||||
{ label: t('common_edit'), onClick: () => startEdit(u) },
|
||||
u.is_active
|
||||
? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) }
|
||||
: { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) },
|
||||
]}
|
||||
onRowClick={canManage ? startEdit : undefined}
|
||||
rowActions={canManage ? (u) => (
|
||||
u.status === 'pending_approval'
|
||||
? [
|
||||
{ label: t('user_approve'), icon: Check, onClick: () => startApprove(u) },
|
||||
{ label: t('user_reject'), icon: XIcon, danger: true, onClick: () => confirmReject(u) },
|
||||
]
|
||||
: [
|
||||
{ label: t('common_edit'), onClick: () => startEdit(u) },
|
||||
u.is_active
|
||||
? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) }
|
||||
: { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) },
|
||||
]
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => setEditing(false)} width={560} height={440}>
|
||||
@@ -136,6 +178,21 @@ export default function UsersPanel() {
|
||||
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
|
||||
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => setApproving(null)} width={420} height={300}>
|
||||
<FieldRow>
|
||||
<Field label={t('user_role')}>
|
||||
<ComboBox options={roleOptions} value={approveForm.roleId} onChange={(v) => setApproveForm((f) => ({ ...f, roleId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('common_department')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={approveForm.departmentId} onChange={(v) => setApproveForm((f) => ({ ...f, departmentId: v }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="accent" icon={Check} onClick={confirmApprove}>{t('user_approve')}</Button>
|
||||
<Button variant="standard" onClick={() => setApproving(null)}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user