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
+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');