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:
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user