feat: invitar a un proyecto por correo a alguien que aun no es usuario

Al agregar un miembro, el popup ahora permite elegir un usuario existente
o escribir un correo nuevo. Si ese correo no pertenece a nadie, se crea
una cuenta en estado 'invited' (nombre/clave placeholder) y se le manda
un correo con un enlace de un solo uso para que complete su registro
(elige su nombre y clave). La fila de project_members se crea de una vez
al invitar, asi que en cuanto acepta ya es miembro sin pasos adicionales.
A diferencia del autorregistro publico, no pasa por aprobacion de un
admin -- quien lo agrego a su proyecto ya lo vetó.

Nuevos endpoints publicos GET /api/auth/invite y POST /api/auth/accept-invite.
Nueva pagina AcceptInvitePage (/aceptar-invitacion). Migracion 004 agrega
el status 'invited' al enum de users.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-09 23:55:02 -05:00
parent 53c7bd7f95
commit d7478d0f21
20 changed files with 320 additions and 23 deletions
@@ -0,0 +1,6 @@
-- Invitar por correo a alguien que aun no es usuario, al agregarlo como miembro de un proyecto.
-- Reutiliza verification_token/verification_token_expires (mismo mecanismo que verificar correo).
SET NAMES utf8mb4;
ALTER TABLE users
MODIFY COLUMN status ENUM('pending_verification','pending_approval','active','invited') NOT NULL DEFAULT 'active';
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "1.6.1",
"version": "1.7.0",
"private": true,
"type": "module",
"main": "src/server.js",
+10
View File
@@ -27,6 +27,16 @@ export const getVerifyEmail = asyncHandler(async (req, res) => {
ok(res, result);
});
export const getInvite = asyncHandler(async (req, res) => {
ok(res, await authService.getInviteInfo(req.query.token));
});
export const postAcceptInvite = asyncHandler(async (req, res) => {
const { token, name, password } = req.body || {};
const result = await authService.acceptInvite(token, name, password);
ok(res, result, 201);
});
export const postForgotPassword = asyncHandler(async (req, res) => {
const { email } = req.body || {};
if (email) await authService.forgotPassword(email);
+3 -1
View File
@@ -3,7 +3,7 @@ import rateLimit from 'express-rate-limit';
import { requireAuth } from '../../middleware/auth.js';
import {
postLogin, postLogout, getMe, postRegister, getVerifyEmail, postForgotPassword, postResetPassword,
putChangePassword,
putChangePassword, getInvite, postAcceptInvite,
} from './controller.js';
const router = Router();
@@ -21,6 +21,8 @@ router.post('/register', loginLimiter, postRegister);
router.get('/verify-email', loginLimiter, getVerifyEmail);
router.post('/forgot-password', loginLimiter, postForgotPassword);
router.post('/reset-password', loginLimiter, postResetPassword);
router.get('/invite', loginLimiter, getInvite);
router.post('/accept-invite', loginLimiter, postAcceptInvite);
router.post('/logout', requireAuth, postLogout);
router.get('/me', requireAuth, getMe);
router.put('/change-password', requireAuth, loginLimiter, putChangePassword);
+52
View File
@@ -11,6 +11,7 @@ import { createNotification } from '../notifications/service.js';
const STATUS_MESSAGES = {
pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.',
pending_approval: 'Tu cuenta esta pendiente de aprobacion por un administrador.',
invited: 'Tienes una invitacion pendiente. Revisa tu correo para completar tu registro.',
};
function newToken() {
@@ -130,6 +131,57 @@ export async function verifyEmail(token) {
return { email: user.email };
}
// Info publica minima para prellenar el formulario de "aceptar invitacion" (solo el correo).
export async function getInviteInfo(token) {
if (!token) throw new HttpError(400, 'Token invalido');
const [rows] = await pool.query(
`SELECT email FROM users WHERE verification_token = ? AND status = 'invited' AND verification_token_expires > NOW()`,
[token]
);
const user = rows[0];
if (!user) throw new HttpError(400, 'La invitacion es invalida o expiro');
return { email: user.email };
}
// Completa la cuenta creada por una invitacion a un proyecto (ver modules/users/service.js
// inviteUser): la persona elige su nombre y clave. A diferencia del autorregistro publico, no
// pasa por aprobacion de un admin — quien la invito ya la vetó al agregarla a su proyecto. Devuelve
// sesion iniciada de una vez, igual que el bootstrap del primer usuario.
export async function acceptInvite(token, name, password) {
if (!token || !name || !password || password.length < 8) {
throw new HttpError(400, 'Nombre, token y una clave de al menos 8 caracteres son obligatorios');
}
const [rows] = await pool.query(
`SELECT id FROM users WHERE verification_token = ? AND status = 'invited' AND verification_token_expires > NOW()`,
[token]
);
const user = rows[0];
if (!user) throw new HttpError(400, 'La invitacion es invalida o expiro');
const passwordHash = await bcrypt.hash(password, 12);
await pool.query(
`UPDATE users SET name = ?, password_hash = ?, is_active = 1, status = 'active', email_verified_at = NOW(),
verification_token = NULL, verification_token_expires = NULL
WHERE id = ?`,
[name, passwordHash, user.id]
);
await logAudit({ userId: user.id, action: 'accept_invite', entityType: 'user', entityId: user.id });
const [full] = await pool.query(
`SELECT u.id, u.name, u.email, u.department_id, r.code AS role_code, r.name AS role_name
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
[user.id]
);
const jwtToken = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
return {
token: jwtToken,
user: {
id: full[0].id, name: full[0].name, email: full[0].email,
roleCode: full[0].role_code, roleName: full[0].role_name, departmentId: full[0].department_id,
},
};
}
export async function forgotPassword(email) {
const [rows] = await pool.query(
`SELECT id, name FROM users WHERE email = ? AND status = 'active' AND is_active = 1`,
+8 -2
View File
@@ -34,8 +34,14 @@ export const deleteProjectPermanent = asyncHandler(async (req, res) => {
});
export const postMember = asyncHandler(async (req, res) => {
const members = await service.addMember(Number(req.params.id), Number(req.body.userId), req.body.roleInProject, req.currentUser);
ok(res, members, 201);
const { userId, email, roleInProject } = req.body;
const result = await service.addMember(
Number(req.params.id),
{ userId: userId ? Number(userId) : null, email },
roleInProject,
req.currentUser,
);
ok(res, result, 201);
});
export const putMemberRole = asyncHandler(async (req, res) => {
+22 -4
View File
@@ -1,4 +1,5 @@
import * as repo from './repository.js';
import * as usersService from '../users/service.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js';
@@ -94,13 +95,30 @@ export async function recalcProjectProgress(projectId) {
await repo.updateProgress(projectId, Math.round(avg * 100) / 100);
}
export async function addMember(projectId, userId, roleInProject, currentUser) {
// Agrega por id de usuario existente O por correo. Si el correo no pertenece a nadie todavia,
// crea una cuenta 'invited' y le manda un correo para que complete su propio registro (nombre y
// clave) — la fila de project_members ya queda creada de una vez, asi que en cuanto acepte la
// invitacion ya aparece como miembro sin pasos adicionales.
export async function addMember(projectId, { userId, email }, roleInProject, currentUser) {
const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
const members = await repo.addMember(projectId, userId, roleInProject);
await logAudit({ userId: currentUser.id, action: 'add_member', entityType: 'project', entityId: projectId, detail: { userId } });
return members;
let targetUserId = userId;
let invited = false;
if (!targetUserId) {
if (!email) throw new HttpError(400, 'Indica un usuario existente o un correo');
const result = await usersService.inviteUser(email, currentUser.id);
targetUserId = result.userId;
invited = result.invited;
}
const members = await repo.addMember(projectId, targetUserId, roleInProject);
await logAudit({
userId: currentUser.id, action: 'add_member', entityType: 'project', entityId: projectId,
detail: { userId: targetUserId, invited },
});
return { members, invited };
}
export async function setMemberAdmin(projectId, userId, isAdmin, currentUser) {
+16
View File
@@ -26,6 +26,22 @@ export async function findByEmail(email) {
return rows[0] || null;
}
export async function findRoleIdByCode(code) {
const [rows] = await pool.query('SELECT id FROM roles WHERE code = ?', [code]);
return rows[0]?.id || null;
}
// Cuenta placeholder para alguien invitado por correo (aun no es usuario del sistema): sin nombre
// ni clave reales todavia (se completan al aceptar la invitacion), status 'invited' bloquea login.
export async function createInvited({ email, roleId, passwordHash, token }) {
const [result] = await pool.query(
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, verification_token, verification_token_expires)
VALUES (?, ?, ?, ?, 0, 'invited', ?, DATE_ADD(NOW(), INTERVAL 7 DAY))`,
[email, email, passwordHash, roleId, token]
);
return result.insertId;
}
export async function create({ name, email, passwordHash, roleId, departmentId, position }) {
const [result] = await pool.query(
`INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active)
+30
View File
@@ -1,11 +1,41 @@
import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import * as repo from './repository.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { sendMail } from '../../utils/mailer.js';
import { env } from '../../config/env.js';
export const listUsers = () => repo.findAll();
export const listRoles = () => repo.listRoles();
// Invita por correo a alguien que todavia no es usuario: crea la cuenta en estado 'invited' (sin
// nombre/clave reales, solo un placeholder) y le manda un enlace de un solo uso para que complete
// su propio registro (elige nombre y clave). Si el correo ya pertenece a un usuario existente (en
// cualquier estado), no crea nada nuevo — quien llama simplemente lo agrega como miembro directo.
export async function inviteUser(email, actorId) {
const existing = await repo.findByEmail(email);
if (existing) return { userId: existing.id, invited: false };
const roleId = await repo.findRoleIdByCode('collaborator');
const passwordHash = await bcrypt.hash(crypto.randomBytes(24).toString('hex'), 12);
const token = crypto.randomBytes(32).toString('hex');
const userId = await repo.createInvited({ email, roleId, passwordHash, token });
await logAudit({ userId: actorId, action: 'invite', entityType: 'user', entityId: userId, detail: { email } });
const inviteUrl = `${env.corsOrigin}/aceptar-invitacion?token=${token}`;
await sendMail({
to: 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>`,
});
return { userId, invited: true };
}
export async function createUser(payload, actorId) {
if (!payload.name || !payload.email || !payload.password || !payload.roleId) {
throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios');