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
+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`,