diff --git a/API.md b/API.md index 8c577a2..a57d19b 100644 --- a/API.md +++ b/API.md @@ -30,6 +30,12 @@ `{ bootstrap: false }` y se envia (o loguea, sin SMTP) un correo de verificación. - `GET /api/auth/verify-email?token=...` — público. Pasa el usuario a `pending_approval` y notifica in-app a los `admin`/`superadmin` activos. +- `GET /api/auth/invite?token=...` — público. `{ email }` de la invitación (para prellenar el + formulario), o `400` si es inválida/expiró. +- `POST /api/auth/accept-invite` — público. `{ token, name, password }` → completa la cuenta + creada por `POST /api/projects/:id/members` con `{ email }` (ver arriba): elige nombre y clave, + pasa a `active` directo (sin aprobación — quien invitó ya la vetó) y devuelve `{ token, user }` + con sesión iniciada, igual que el bootstrap del primer usuario. - `POST /api/auth/forgot-password` — público. `{ email }` → respuesta **siempre genérica**. - `POST /api/auth/reset-password` — público. `{ token, password }`. - `PUT /api/auth/change-password` — autenticado. `{ currentPassword, newPassword }`, exige la @@ -53,10 +59,13 @@ editar el proyecto. `POST /api/projects/:id/restore` y `DELETE /api/projects/:id/permanent` (borrado físico, irreversible, arrastra actividades/comentarios/adjuntos) — ambos solo superadmin; `permanent` exige que el proyecto ya esté en la papelera (400 si no). -- `POST /api/projects/:id/members` (`roleInProject` opcional: `'admin'` = administrador del - proyecto) · `PUT /api/projects/:id/members/:userId/role` (`{ admin: true|false }`, otorga o - quita el rol de administrador **de ese proyecto** — un proyecto puede tener uno o más - administradores, sin importar el rol global del usuario; nunca toca al `owner`) · +- `POST /api/projects/:id/members` — `{ userId }` (usuario existente) o `{ email }` (si el correo + no pertenece a nadie todavía, crea una cuenta `invited` y le manda un correo de invitación para + que complete su registro; la fila de `project_members` ya queda creada de una vez). `roleInProject` + opcional: `'admin'` = administrador del proyecto. Responde `{ members, invited }`. +- `PUT /api/projects/:id/members/:userId/role` (`{ admin: true|false }`, otorga o quita el rol de + administrador **de ese proyecto** — un proyecto puede tener uno o más administradores, sin + importar el rol global del usuario; nunca toca al `owner`) · `DELETE /api/projects/:id/members/:userId` ### Actividades (`/api/activities`) diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 7bafcf5..318fa96 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -59,10 +59,17 @@ editables directo por el cliente: actividad con subtareas = % de subtareas hechas; actividad sin subtareas = manual (0-100); proyecto = promedio del avance de sus actividades. - `users.password_hash` con bcrypt, nunca texto plano ni reversible. -- `users.status` (`pending_verification`/`pending_approval`/`active`) es el ciclo de vida del - autorregistro, independiente de `is_active` (que sigue siendo el interruptor manual del admin +- `users.status` (`pending_verification`/`pending_approval`/`active`/`invited`) es el ciclo de vida + de la cuenta, independiente de `is_active` (que sigue siendo el interruptor manual del admin para desactivar a alguien ya activo). Un usuario `pending_approval` rechazado se **borra** físicamente (no hay estado "rechazado" — se audita el email en `audit_log` antes de borrar). +- `invited`: cuenta placeholder creada al agregar por correo a alguien que aún no es usuario desde + Miembros de un proyecto (`name`=`email`, `password_hash` aleatorio inservible, reutiliza + `verification_token`/`verification_token_expires` — 7 días en vez de 24h). Al aceptar la + invitación (`POST /api/auth/accept-invite`) pasa directo a `active` **sin pasar por + `pending_approval`** — a diferencia del autorregistro público, aquí quien lo invitó ya lo vetó al + agregarlo a su proyecto. La fila de `project_members` se crea en el momento de invitar, no al + aceptar, así que en cuanto acepta ya aparece como miembro sin pasos adicionales. - `system_settings.value` para `smtp_pass_enc` va **cifrado** (AES-256-GCM, `ENCRYPTION_KEY` del `.env`) — nunca en texto plano, nunca se devuelve por la API (solo `configured: true/false`). - Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios **activos** con diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ca24b..7fb17bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog - project.linkedcorp.net +## [1.7.0] - 2026-09-10 +### Added +- Agregar miembro a un proyecto ahora acepta un **correo que todavía no es usuario del sistema** + (campo "Invitar por correo" en el popup de Miembros, alternativo a elegir uno existente). Si el + correo no pertenece a nadie, se crea una cuenta en estado `invited` y se le manda un correo con + un enlace de un solo uso (`/aceptar-invitacion?token=...`, vence en 7 días) para que complete su + propio registro (elige nombre y clave); la fila de `project_members` ya queda creada de una vez, + así que en cuanto acepta ya aparece como miembro sin pasos adicionales. A diferencia del + autorregistro público, no pasa por aprobación de un admin — quien lo agregó a su proyecto ya lo + vetó. Nuevos endpoints públicos `GET /api/auth/invite` y `POST /api/auth/accept-invite`. + ## [1.6.1] - 2026-09-09 ### Changed - Nuevo ícono/logo de la app derivado del ícono original de LinkedCorp (círculo azul de marca diff --git a/VERSION b/VERSION index 9c6d629..bd8bf88 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.1 +1.7.0 diff --git a/backend/database/migrations/004_project_member_invite.sql b/backend/database/migrations/004_project_member_invite.sql new file mode 100644 index 0000000..9a2b143 --- /dev/null +++ b/backend/database/migrations/004_project_member_invite.sql @@ -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'; diff --git a/backend/package.json b/backend/package.json index a2ce0c7..9b83ad7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.6.1", + "version": "1.7.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/auth/controller.js b/backend/src/modules/auth/controller.js index c649c7e..6494e30 100644 --- a/backend/src/modules/auth/controller.js +++ b/backend/src/modules/auth/controller.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); diff --git a/backend/src/modules/auth/routes.js b/backend/src/modules/auth/routes.js index 87ca64c..0d7d8de 100644 --- a/backend/src/modules/auth/routes.js +++ b/backend/src/modules/auth/routes.js @@ -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); diff --git a/backend/src/modules/auth/service.js b/backend/src/modules/auth/service.js index c79e797..3dd2b26 100644 --- a/backend/src/modules/auth/service.js +++ b/backend/src/modules/auth/service.js @@ -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`, diff --git a/backend/src/modules/projects/controller.js b/backend/src/modules/projects/controller.js index 93b5645..67914b2 100644 --- a/backend/src/modules/projects/controller.js +++ b/backend/src/modules/projects/controller.js @@ -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) => { diff --git a/backend/src/modules/projects/service.js b/backend/src/modules/projects/service.js index 9f02c1d..16cb131 100644 --- a/backend/src/modules/projects/service.js +++ b/backend/src/modules/projects/service.js @@ -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) { diff --git a/backend/src/modules/users/repository.js b/backend/src/modules/users/repository.js index 18e8b5f..da0599e 100644 --- a/backend/src/modules/users/repository.js +++ b/backend/src/modules/users/repository.js @@ -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) diff --git a/backend/src/modules/users/service.js b/backend/src/modules/users/service.js index f5ec00c..d1e189c 100644 --- a/backend/src/modules/users/service.js +++ b/backend/src/modules/users/service.js @@ -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: `
Te agregaron como miembro de un proyecto en project.linkedcorp.net.
+Completa tu registro para acceder (elige tu nombre y una clave):
+Este enlace vence en 7 dias.
`, + }); + + 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'); diff --git a/frontend/package.json b/frontend/package.json index cec12f5..f36583c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.6.1", + "version": "1.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index b22c361..0d2a0c4 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -10,6 +10,7 @@ import RegisterPage from '../pages/Login/RegisterPage.jsx'; import VerifyEmailPage from '../pages/Login/VerifyEmailPage.jsx'; import ForgotPasswordPage from '../pages/Login/ForgotPasswordPage.jsx'; import ResetPasswordPage from '../pages/Login/ResetPasswordPage.jsx'; +import AcceptInvitePage from '../pages/Login/AcceptInvitePage.jsx'; import DashboardPage from '../pages/Dashboard/DashboardPage.jsx'; import OrganizacionPage from '../pages/Organizacion/OrganizacionPage.jsx'; import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx'; @@ -27,6 +28,7 @@ export default function App() {