diff --git a/CHANGELOG.md b/CHANGELOG.md index 0efceca..126a7f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index c6436a8..5f4f65c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.10.2 +2.10.3 diff --git a/backend/package.json b/backend/package.json index 4ace589..d992d27 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.10.2", + "version": "2.10.3", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/users/controller.js b/backend/src/modules/users/controller.js index 0b4a9a9..cd7290e 100644 --- a/backend/src/modules/users/controller.js +++ b/backend/src/modules/users/controller.js @@ -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)); +}); diff --git a/backend/src/modules/users/repository.js b/backend/src/modules/users/repository.js index da0599e..ae4141b 100644 --- a/backend/src/modules/users/repository.js +++ b/backend/src/modules/users/repository.js @@ -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; diff --git a/backend/src/modules/users/routes.js b/backend/src/modules/users/routes.js index 39b419e..e14bc4f 100644 --- a/backend/src/modules/users/routes.js +++ b/backend/src/modules/users/routes.js @@ -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; diff --git a/backend/src/modules/users/service.js b/backend/src/modules/users/service.js index da2d5e9..ee3b462 100644 --- a/backend/src/modules/users/service.js +++ b/backend/src/modules/users/service.js @@ -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: `

Te agregaron como miembro de un proyecto en project.linkedcorp.net.

+

Completa tu registro para acceder (elige tu nombre y una clave):

+

${inviteUrl}

Este enlace vence en 7 dias.

`, + }); + } 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: `

Hola ${existing.name},

Confirma tu correo para continuar tu registro:

${verifyUrl}

Este enlace vence en 24 horas.

`, + }); + } + + 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). diff --git a/frontend/package.json b/frontend/package.json index af2c7d4..f46c752 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.10.2", + "version": "2.10.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 11e09a1..94436ea 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -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', diff --git a/frontend/src/pages/Organizacion/UsersPanel.jsx b/frontend/src/pages/Organizacion/UsersPanel.jsx index 48518e2..5845b29 100644 --- a/frontend/src/pages/Organizacion/UsersPanel.jsx +++ b/frontend/src/pages/Organizacion/UsersPanel.jsx @@ -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) }, diff --git a/frontend/src/services/users.js b/frontend/src/services/users.js index b0bb7d9..eb378d2 100644 --- a/frontend/src/services/users.js +++ b/frontend/src/services/users.js @@ -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`);