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:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [2.10.2] - 2026-10-12
|
||||||
### Added
|
### Added
|
||||||
- Tarjetas de Notas: ícono junto a la fecha cuando la nota está compartida (ya sea que la
|
- Tarjetas de Notas: ícono junto a la fecha cuando la nota está compartida (ya sea que la
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.10.2",
|
"version": "2.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -28,3 +28,7 @@ export const postRejectUser = asyncHandler(async (req, res) => {
|
|||||||
await service.rejectUser(Number(req.params.id), req.currentUser.id);
|
await service.rejectUser(Number(req.params.id), req.currentUser.id);
|
||||||
ok(res, { rejected: true });
|
ok(res, { rejected: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const postResendUserEmail = asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await service.resendUserEmail(Number(req.params.id), req.currentUser.id));
|
||||||
|
});
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ export async function remove(id) {
|
|||||||
await pool.query('DELETE FROM users WHERE id = ?', [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() {
|
export async function listRoles() {
|
||||||
const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id');
|
const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id');
|
||||||
return rows;
|
return rows;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
|
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();
|
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.patch('/:id/active', requireRole(...CAN_MANAGE_USERS), patchUserActive);
|
||||||
router.post('/:id/approve', requireRole(...CAN_MANAGE_USERS), postApproveUser);
|
router.post('/:id/approve', requireRole(...CAN_MANAGE_USERS), postApproveUser);
|
||||||
router.post('/:id/reject', requireRole(...CAN_MANAGE_USERS), postRejectUser);
|
router.post('/:id/reject', requireRole(...CAN_MANAGE_USERS), postRejectUser);
|
||||||
|
router.post('/:id/resend', requireRole(...CAN_MANAGE_USERS), postResendUserEmail);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -79,6 +79,44 @@ export async function approveUser(id, payload, actorId) {
|
|||||||
return user;
|
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
|
// 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
|
// 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).
|
// 'pending_verification' — antes no habia forma de quitarla desde la UI).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.10.2",
|
"version": "2.10.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ export const messages = {
|
|||||||
user_status_pending_verification: 'Pendiente de verificar correo',
|
user_status_pending_verification: 'Pendiente de verificar correo',
|
||||||
user_status_pending_approval: 'Pendiente de aprobacion',
|
user_status_pending_approval: 'Pendiente de aprobacion',
|
||||||
user_status_active: 'Activo',
|
user_status_active: 'Activo',
|
||||||
|
user_status_invited: 'Invitacion pendiente',
|
||||||
user_approve: 'Aprobar',
|
user_approve: 'Aprobar',
|
||||||
user_reject: 'Rechazar',
|
user_reject: 'Rechazar',
|
||||||
user_approve_title: 'Aprobar usuario',
|
user_approve_title: 'Aprobar usuario',
|
||||||
user_reject_confirm_msg: '¿Seguro que deseas rechazar esta solicitud de registro? Se eliminara permanentemente.',
|
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',
|
config_company: 'Empresa',
|
||||||
company_name: 'Nombre de la empresa',
|
company_name: 'Nombre de la empresa',
|
||||||
@@ -383,10 +386,13 @@ export const messages = {
|
|||||||
user_status_pending_verification: 'Pending email verification',
|
user_status_pending_verification: 'Pending email verification',
|
||||||
user_status_pending_approval: 'Pending approval',
|
user_status_pending_approval: 'Pending approval',
|
||||||
user_status_active: 'Active',
|
user_status_active: 'Active',
|
||||||
|
user_status_invited: 'Invite pending',
|
||||||
user_approve: 'Approve',
|
user_approve: 'Approve',
|
||||||
user_reject: 'Reject',
|
user_reject: 'Reject',
|
||||||
user_approve_title: 'Approve user',
|
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_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',
|
config_company: 'Company',
|
||||||
company_name: 'Company name',
|
company_name: 'Company name',
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
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 { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
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 { listDepartments } from '../../services/departments.js';
|
||||||
import { ListView } from '../../components/ListView.jsx';
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
@@ -19,7 +19,7 @@ function flatten(nodes, acc = []) {
|
|||||||
return 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: '' };
|
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '' };
|
||||||
|
|
||||||
export default function UsersPanel() {
|
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) {
|
function confirmReject(user) {
|
||||||
openDialog({
|
openDialog({
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
@@ -140,7 +147,13 @@ export default function UsersPanel() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (u.status === 'pending_verification') {
|
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 [
|
return [
|
||||||
{ label: t('common_edit'), onClick: () => startEdit(u) },
|
{ label: t('common_edit'), onClick: () => startEdit(u) },
|
||||||
|
|||||||
@@ -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 setUserActive = (id, isActive) => api.patch(`/users/${id}/active`, { isActive });
|
||||||
export const approveUser = (id, data) => api.post(`/users/${id}/approve`, data);
|
export const approveUser = (id, data) => api.post(`/users/${id}/approve`, data);
|
||||||
export const rejectUser = (id) => api.post(`/users/${id}/reject`);
|
export const rejectUser = (id) => api.post(`/users/${id}/reject`);
|
||||||
|
export const resendUserEmail = (id) => api.post(`/users/${id}/resend`);
|
||||||
|
|||||||
Reference in New Issue
Block a user