From d7478d0f21acfdfc7effb74762a2f9047af10b7c Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Wed, 9 Sep 2026 23:55:02 -0500 Subject: [PATCH] feat: invitar a un proyecto por correo a alguien que aun no es usuario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- API.md | 17 +++- BASE_DE_DATOS.md | 11 ++- CHANGELOG.md | 11 +++ VERSION | 2 +- .../migrations/004_project_member_invite.sql | 6 ++ backend/package.json | 2 +- backend/src/modules/auth/controller.js | 10 +++ backend/src/modules/auth/routes.js | 4 +- backend/src/modules/auth/service.js | 52 +++++++++++ backend/src/modules/projects/controller.js | 10 ++- backend/src/modules/projects/service.js | 26 +++++- backend/src/modules/users/repository.js | 16 ++++ backend/src/modules/users/service.js | 30 +++++++ frontend/package.json | 2 +- frontend/src/app/App.jsx | 2 + frontend/src/i18n/messages.js | 16 ++++ frontend/src/pages/Login/AcceptInvitePage.jsx | 90 +++++++++++++++++++ .../src/pages/Proyectos/ProjectMembersTab.jsx | 32 +++++-- frontend/src/services/auth.js | 2 + frontend/src/services/projects.js | 2 +- 20 files changed, 320 insertions(+), 23 deletions(-) create mode 100644 backend/database/migrations/004_project_member_invite.sql create mode 100644 frontend/src/pages/Login/AcceptInvitePage.jsx 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):

+

${inviteUrl}

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() { } /> } /> } /> + } /> }> } /> } /> diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 9bb8adc..b3d253e 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -25,6 +25,10 @@ export const messages = { verify_email_title: 'Verificar correo', verify_email_success: 'Correo confirmado. Tu cuenta esta pendiente de aprobacion por un administrador.', verify_email_missing_token: 'Enlace invalido: falta el token de verificacion.', + accept_invite_title: 'Completar registro', + accept_invite_hint: 'Te invitaron a un proyecto. Elige tu nombre y una clave para crear tu cuenta.', + accept_invite_submit: 'Crear mi cuenta', + accept_invite_missing_token: 'Enlace invalido: falta el token de invitacion.', forgot_password_title: 'Recuperar clave', forgot_password_submit: 'Enviar instrucciones', forgot_password_sent: 'Si el correo existe, te enviamos instrucciones para recuperar tu clave.', @@ -72,6 +76,7 @@ export const messages = { common_view: 'Ver', common_yes: 'Si', common_no: 'No', + common_or: 'o', common_loading: 'Cargando...', common_empty: 'No hay datos para mostrar', common_confirm_delete_title: 'Confirmar eliminacion', @@ -127,6 +132,9 @@ export const messages = { project_role_admin: 'Administrador', project_make_admin: 'Hacer administrador', project_remove_admin: 'Quitar administrador', + project_invite_email: 'Invitar por correo', + project_invite_email_hint: 'Si el correo no pertenece a nadie todavia, se le manda un enlace para que complete su registro y se una al proyecto.', + project_invite_sent: 'Invitacion enviada por correo', project_created_at: 'Creado', project_deleted_at: 'Eliminado', project_delete_confirm_msg: '¿Seguro que deseas eliminar este proyecto? Se movera a la papelera y dejara de verse en el listado; un superadministrador puede restaurarlo o borrarlo definitivamente.', @@ -225,6 +233,10 @@ export const messages = { verify_email_title: 'Verify email', verify_email_success: 'Email confirmed. Your account is pending administrator approval.', verify_email_missing_token: 'Invalid link: missing verification token.', + accept_invite_title: 'Complete your registration', + accept_invite_hint: 'You were invited to a project. Choose your name and a password to create your account.', + accept_invite_submit: 'Create my account', + accept_invite_missing_token: 'Invalid link: missing invite token.', forgot_password_title: 'Recover password', forgot_password_submit: 'Send instructions', forgot_password_sent: 'If the email exists, we sent instructions to recover your password.', @@ -272,6 +284,7 @@ export const messages = { common_view: 'View', common_yes: 'Yes', common_no: 'No', + common_or: 'or', common_loading: 'Loading...', common_empty: 'No data to show', common_confirm_delete_title: 'Confirm delete', @@ -327,6 +340,9 @@ export const messages = { project_role_admin: 'Administrator', project_make_admin: 'Make administrator', project_remove_admin: 'Remove administrator', + project_invite_email: 'Invite by email', + project_invite_email_hint: "If the email doesn't belong to anyone yet, they get a link to finish signing up and join the project.", + project_invite_sent: 'Invite sent by email', project_created_at: 'Created', project_deleted_at: 'Deleted', project_delete_confirm_msg: 'Are you sure you want to delete this project? It will move to the trash and disappear from the list; a superadmin can restore it or delete it permanently.', diff --git a/frontend/src/pages/Login/AcceptInvitePage.jsx b/frontend/src/pages/Login/AcceptInvitePage.jsx new file mode 100644 index 0000000..c9d9bb1 --- /dev/null +++ b/frontend/src/pages/Login/AcceptInvitePage.jsx @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { UserPlus } from 'lucide-react'; +import { useAuth } from '../../context/auth-context.jsx'; +import { useUi } from '../../context/ui-context.jsx'; +import { getInviteInfo, acceptInvite } from '../../services/auth.js'; +import { Field } from '../../components/Field.jsx'; +import { Button } from '../../components/Button.jsx'; +import { PasswordInput } from '../../components/PasswordInput.jsx'; +import logoDark from '../../assets/logoLinkedCorpTxt.png'; +import logoLight from '../../assets/logoLinkedCorpTxtBlack.png'; + +export default function AcceptInvitePage() { + const { setSession } = useAuth(); + const { t, theme } = useUi(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const token = searchParams.get('token') || ''; + const requestedTokenRef = useRef(null); + + const [state, setState] = useState('loading'); // loading | ready | error + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!token) { setState('error'); setError(t('accept_invite_missing_token')); return; } + if (requestedTokenRef.current === token) return; + requestedTokenRef.current = token; + getInviteInfo(token) + .then((info) => { setEmail(info.email); setState('ready'); }) + .catch((err) => { setState('error'); setError(err.message || t('common_error')); }); + }, [token, t]); + + async function handleSubmit(e) { + e.preventDefault(); + setError(''); + if (password !== confirm) { setError(t('register_password_mismatch')); return; } + if (password.length < 8) { setError(t('register_password_short')); return; } + setLoading(true); + try { + const result = await acceptInvite(token, name, password); + setSession(result.token, result.user); + navigate('/', { replace: true }); + } catch (err) { + setError(err.message || t('common_error')); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ {t('appName')} +
+
{t('accept_invite_title')}
+ {error &&
{error}
} + {state === 'loading' &&
{t('common_loading')}
} + {state === 'ready' && ( + <> +
{t('accept_invite_hint')}
+ + + + + setName(e.target.value)} required autoFocus /> + + + setPassword(e.target.value)} required /> + + + setConfirm(e.target.value)} required /> + + + + )} +
+ {t('register_back_to_login')} +
+
+
+ ); +} diff --git a/frontend/src/pages/Proyectos/ProjectMembersTab.jsx b/frontend/src/pages/Proyectos/ProjectMembersTab.jsx index 390418b..8709376 100644 --- a/frontend/src/pages/Proyectos/ProjectMembersTab.jsx +++ b/frontend/src/pages/Proyectos/ProjectMembersTab.jsx @@ -13,24 +13,31 @@ import { DetailPopup } from '../../components/DetailPopup.jsx'; const ROLE_BADGE = { owner: { color: 'accent', key: 'project_role_owner' }, admin: { color: 'success', key: 'project_role_admin' } }; export default function ProjectMembersTab({ project, onChanged }) { - const { t, openDialog } = useUi(); + const { t, openDialog, pushToast } = useUi(); const runAction = useAction(); const { data: users } = useAsync(listUsers, []); const [addingMember, setAddingMember] = useState(''); + const [inviteEmail, setInviteEmail] = useState(''); const [addAsAdmin, setAddAsAdmin] = useState(false); const [addMemberOpen, setAddMemberOpen] = useState(false); const userOptions = (users || []).filter((u) => !project.members.some((m) => m.user_id === u.id)).map((u) => ({ value: u.id, label: u.name })); + const canAdd = !!addingMember || !!inviteEmail.trim(); async function addMember() { - if (!addingMember) return; + if (!canAdd) return; await runAction(async () => { - await addProjectMember(project.id, Number(addingMember), addAsAdmin ? 'admin' : null); + const target = inviteEmail.trim() + ? { email: inviteEmail.trim() } + : { userId: Number(addingMember) }; + const result = await addProjectMember(project.id, target, addAsAdmin ? 'admin' : null); setAddingMember(''); + setInviteEmail(''); setAddAsAdmin(false); setAddMemberOpen(false); onChanged(); + pushToast({ tone: 'success', message: result.invited ? t('project_invite_sent') : t('common_saved') }); }); } @@ -82,16 +89,29 @@ export default function ProjectMembersTab({ project, onChanged }) { {project.members.length === 0 &&
{t('common_empty')}
} - setAddMemberOpen(false)} width={420} height={300}> + setAddMemberOpen(false)} width={420} height={380}> - + { setAddingMember(v); setInviteEmail(''); }} + placeholder={t('project_add_member')} + /> + +
{t('common_or')}
+ + { setInviteEmail(e.target.value); setAddingMember(''); }} + placeholder="correo@dominio.com" + />
- +
diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index be1fbf3..e91e29f 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -5,3 +5,5 @@ export const verifyEmail = (token) => api.get(`/auth/verify-email?token=${encode export const forgotPassword = (email) => api.post('/auth/forgot-password', { email }); export const resetPassword = (token, password) => api.post('/auth/reset-password', { token, password }); export const changePassword = (currentPassword, newPassword) => api.put('/auth/change-password', { currentPassword, newPassword }); +export const getInviteInfo = (token) => api.get(`/auth/invite?token=${encodeURIComponent(token)}`); +export const acceptInvite = (token, name, password) => api.post('/auth/accept-invite', { token, name, password }); diff --git a/frontend/src/services/projects.js b/frontend/src/services/projects.js index 5b8ae73..2a831d6 100644 --- a/frontend/src/services/projects.js +++ b/frontend/src/services/projects.js @@ -10,7 +10,7 @@ export const updateProject = (id, data) => api.put(`/projects/${id}`, data); export const deleteProject = (id) => api.delete(`/projects/${id}`); export const restoreProject = (id) => api.post(`/projects/${id}/restore`); export const permanentlyDeleteProject = (id) => api.delete(`/projects/${id}/permanent`); -export const addProjectMember = (id, userId, roleInProject) => api.post(`/projects/${id}/members`, { userId, roleInProject }); +export const addProjectMember = (id, { userId, email }, roleInProject) => api.post(`/projects/${id}/members`, { userId, email, roleInProject }); export const setProjectMemberAdmin = (id, userId, admin) => api.put(`/projects/${id}/members/${userId}/role`, { admin }); export const removeProjectMember = (id, userId) => api.delete(`/projects/${id}/members/${userId}`); export const getGantt = (id) => api.get(`/projects/${id}/gantt`);