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:
@@ -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