feat: version 2.10.3 -- reenviar correo de invitacion/verificacion desde Usuarios

Nueva opcion "Reenviar correo" en el menu de 3 puntos de Organizacion -> Usuarios, para
usuarios en estado 'invited' o 'pending_verification'. Reusa las mismas columnas
(verification_token/verification_token_expires) y plantillas que el envio original
(inviteUser / auth/service.js#register), solo genera un token nuevo -- el anterior pudo
vencer o perderse en spam.

De paso se corrige que el estado "invited" no tenia rama propia en el menu de acciones (caia
en el branch generico de editar/activar-desactivar, que no aplica a una cuenta placeholder sin
clave real todavia) ni traduccion de su badge (user_status_invited faltaba en i18n).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-12 16:02:24 -05:00
parent 98d9196791
commit 2d1bfc0bfd
11 changed files with 90 additions and 8 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog - project.linkedcorp.net
## [2.10.3] - 2026-10-12
### Added
- Organización → Usuarios: nueva opción "Reenviar correo" en el menú de 3 puntos para usuarios
con invitación o verificación de correo pendiente — genera un enlace nuevo (el anterior puede
haber vencido o perderse) y reenvía el mismo correo original.
### Fixed
- El estado "Invitación pendiente" se mostraba sin traducir en la tabla de usuarios.
## [2.10.2] - 2026-10-12
### Added
- Tarjetas de Notas: ícono junto a la fecha cuando la nota está compartida (ya sea que la
+1 -1
View File
@@ -1 +1 @@
2.10.2
2.10.3
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "2.10.2",
"version": "2.10.3",
"private": true,
"type": "module",
"main": "src/server.js",
+4
View File
@@ -28,3 +28,7 @@ export const postRejectUser = asyncHandler(async (req, res) => {
await service.rejectUser(Number(req.params.id), req.currentUser.id);
ok(res, { rejected: true });
});
export const postResendUserEmail = asyncHandler(async (req, res) => {
ok(res, await service.resendUserEmail(Number(req.params.id), req.currentUser.id));
});
+9
View File
@@ -77,6 +77,15 @@ export async function remove(id) {
await pool.query('DELETE FROM users WHERE id = ?', [id]);
}
// Reusa verification_token/verification_token_expires -- misma columna que invited y
// pending_verification ya usan (ver inviteUser/register), solo se refresca con un token nuevo.
export async function refreshVerificationToken(id, token, expiresHours) {
await pool.query(
'UPDATE users SET verification_token = ?, verification_token_expires = DATE_ADD(NOW(), INTERVAL ? HOUR) WHERE id = ?',
[token, expiresHours, id]
);
}
export async function listRoles() {
const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id');
return rows;
+4 -1
View File
@@ -1,6 +1,8 @@
import { Router } from 'express';
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
import { getUsers, getRoles, postUser, putUser, patchUserActive, postApproveUser, postRejectUser } from './controller.js';
import {
getUsers, getRoles, postUser, putUser, patchUserActive, postApproveUser, postRejectUser, postResendUserEmail,
} from './controller.js';
const router = Router();
@@ -12,5 +14,6 @@ router.put('/:id', requireRole(...CAN_MANAGE_USERS), putUser);
router.patch('/:id/active', requireRole(...CAN_MANAGE_USERS), patchUserActive);
router.post('/:id/approve', requireRole(...CAN_MANAGE_USERS), postApproveUser);
router.post('/:id/reject', requireRole(...CAN_MANAGE_USERS), postRejectUser);
router.post('/:id/resend', requireRole(...CAN_MANAGE_USERS), postResendUserEmail);
export default router;
+38
View File
@@ -79,6 +79,44 @@ export async function approveUser(id, payload, actorId) {
return user;
}
// Reenvia el correo pendiente (invitacion o verificacion) con un token nuevo -- el anterior
// pudo vencer, perderse, o caer en spam. Mismas plantillas que el envio original (inviteUser /
// auth/service.js#register), solo cambia el token y su vencimiento.
const RESENDABLE_STATUSES = new Set(['invited', 'pending_verification']);
export async function resendUserEmail(id, actorId) {
const existing = await repo.findById(id);
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
if (!RESENDABLE_STATUSES.has(existing.status)) {
throw new HttpError(400, 'Este usuario no tiene un correo pendiente de confirmar');
}
const token = crypto.randomBytes(32).toString('hex');
if (existing.status === 'invited') {
await repo.refreshVerificationToken(id, token, 24 * 7);
const inviteUrl = `${env.corsOrigin}/aceptar-invitacion?token=${token}`;
await sendMail({
to: existing.email,
subject: 'Te invitaron a project.linkedcorp.net',
html: `<p>Te agregaron como miembro de un proyecto en project.linkedcorp.net.</p>
<p>Completa tu registro para acceder (elige tu nombre y una clave):</p>
<p><a href="${inviteUrl}">${inviteUrl}</a></p><p>Este enlace vence en 7 dias.</p>`,
});
} else {
await repo.refreshVerificationToken(id, token, 24);
const verifyUrl = `${env.corsOrigin}/verificar-correo?token=${token}`;
await sendMail({
to: existing.email,
subject: 'Confirma tu correo — project.linkedcorp.net',
html: `<p>Hola ${existing.name},</p><p>Confirma tu correo para continuar tu registro:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>Este enlace vence en 24 horas.</p>`,
});
}
await logAudit({ userId: actorId, action: 'resend_email', entityType: 'user', entityId: id, detail: { status: existing.status } });
return { sent: true };
}
// Rechaza y borra una cuenta que aun no esta activa: tanto la que ya paso la verificacion de
// correo y espera aprobacion, como la que nunca llego a confirmar su correo (quedo abandonada en
// 'pending_verification' — antes no habia forma de quitarla desde la UI).
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "2.10.2",
"version": "2.10.3",
"type": "module",
"scripts": {
"dev": "vite",
+6
View File
@@ -43,10 +43,13 @@ export const messages = {
user_status_pending_verification: 'Pendiente de verificar correo',
user_status_pending_approval: 'Pendiente de aprobacion',
user_status_active: 'Activo',
user_status_invited: 'Invitacion pendiente',
user_approve: 'Aprobar',
user_reject: 'Rechazar',
user_approve_title: 'Aprobar usuario',
user_reject_confirm_msg: '¿Seguro que deseas rechazar esta solicitud de registro? Se eliminara permanentemente.',
user_resend: 'Reenviar correo',
user_resend_sent: 'Correo reenviado',
config_company: 'Empresa',
company_name: 'Nombre de la empresa',
@@ -383,10 +386,13 @@ export const messages = {
user_status_pending_verification: 'Pending email verification',
user_status_pending_approval: 'Pending approval',
user_status_active: 'Active',
user_status_invited: 'Invite pending',
user_approve: 'Approve',
user_reject: 'Reject',
user_approve_title: 'Approve user',
user_reject_confirm_msg: 'Are you sure you want to reject this registration request? It will be permanently deleted.',
user_resend: 'Resend email',
user_resend_sent: 'Email resent',
config_company: 'Company',
company_name: 'Company name',
+17 -4
View File
@@ -1,9 +1,9 @@
import { useState } from 'react';
import { Plus, Save, UserX, UserCheck, Check, X as XIcon } from 'lucide-react';
import { Plus, Save, UserX, UserCheck, Check, X as XIcon, Mail } 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, approveUser, rejectUser } from '../../services/users.js';
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail } from '../../services/users.js';
import { listDepartments } from '../../services/departments.js';
import { ListView } from '../../components/ListView.jsx';
import { SearchInput } from '../../components/SearchInput.jsx';
@@ -19,7 +19,7 @@ function flatten(nodes, acc = []) {
return acc;
}
const STATUS_COLOR = { pending_verification: 'muted', pending_approval: 'warning', active: 'success' };
const STATUS_COLOR = { pending_verification: 'muted', pending_approval: 'warning', active: 'success', invited: 'muted' };
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '' };
export default function UsersPanel() {
@@ -92,6 +92,13 @@ export default function UsersPanel() {
});
}
function resendEmail(user) {
runAction(async () => {
await resendUserEmail(user.id);
pushToast({ tone: 'success', message: t('user_resend_sent') });
});
}
function confirmReject(user) {
openDialog({
tone: 'warning',
@@ -140,7 +147,13 @@ export default function UsersPanel() {
];
}
if (u.status === 'pending_verification') {
return [{ label: t('user_reject'), icon: XIcon, danger: true, onClick: () => confirmReject(u) }];
return [
{ label: t('user_resend'), icon: Mail, onClick: () => resendEmail(u) },
{ label: t('user_reject'), icon: XIcon, danger: true, onClick: () => confirmReject(u) },
];
}
if (u.status === 'invited') {
return [{ label: t('user_resend'), icon: Mail, onClick: () => resendEmail(u) }];
}
return [
{ label: t('common_edit'), onClick: () => startEdit(u) },
+1
View File
@@ -7,3 +7,4 @@ export const updateUser = (id, data) => api.put(`/users/${id}`, data);
export const setUserActive = (id, isActive) => api.patch(`/users/${id}/active`, { isActive });
export const approveUser = (id, data) => api.post(`/users/${id}/approve`, data);
export const rejectUser = (id) => api.post(`/users/${id}/reject`);
export const resendUserEmail = (id) => api.post(`/users/${id}/resend`);