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:
@@ -30,6 +30,12 @@
|
|||||||
`{ bootstrap: false }` y se envia (o loguea, sin SMTP) un correo de verificación.
|
`{ 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
|
- `GET /api/auth/verify-email?token=...` — público. Pasa el usuario a `pending_approval` y notifica
|
||||||
in-app a los `admin`/`superadmin` activos.
|
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/forgot-password` — público. `{ email }` → respuesta **siempre genérica**.
|
||||||
- `POST /api/auth/reset-password` — público. `{ token, password }`.
|
- `POST /api/auth/reset-password` — público. `{ token, password }`.
|
||||||
- `PUT /api/auth/change-password` — autenticado. `{ currentPassword, newPassword }`, exige la
|
- `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`
|
editar el proyecto. `POST /api/projects/:id/restore` y `DELETE /api/projects/:id/permanent`
|
||||||
(borrado físico, irreversible, arrastra actividades/comentarios/adjuntos) — ambos solo
|
(borrado físico, irreversible, arrastra actividades/comentarios/adjuntos) — ambos solo
|
||||||
superadmin; `permanent` exige que el proyecto ya esté en la papelera (400 si no).
|
superadmin; `permanent` exige que el proyecto ya esté en la papelera (400 si no).
|
||||||
- `POST /api/projects/:id/members` (`roleInProject` opcional: `'admin'` = administrador del
|
- `POST /api/projects/:id/members` — `{ userId }` (usuario existente) o `{ email }` (si el correo
|
||||||
proyecto) · `PUT /api/projects/:id/members/:userId/role` (`{ admin: true|false }`, otorga o
|
no pertenece a nadie todavía, crea una cuenta `invited` y le manda un correo de invitación para
|
||||||
quita el rol de administrador **de ese proyecto** — un proyecto puede tener uno o más
|
que complete su registro; la fila de `project_members` ya queda creada de una vez). `roleInProject`
|
||||||
administradores, sin importar el rol global del usuario; nunca toca al `owner`) ·
|
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`
|
`DELETE /api/projects/:id/members/:userId`
|
||||||
|
|
||||||
### Actividades (`/api/activities`)
|
### Actividades (`/api/activities`)
|
||||||
|
|||||||
+9
-2
@@ -59,10 +59,17 @@
|
|||||||
editables directo por el cliente: actividad con subtareas = % de subtareas hechas; actividad sin
|
editables directo por el cliente: actividad con subtareas = % de subtareas hechas; actividad sin
|
||||||
subtareas = manual (0-100); proyecto = promedio del avance de sus actividades.
|
subtareas = manual (0-100); proyecto = promedio del avance de sus actividades.
|
||||||
- `users.password_hash` con bcrypt, nunca texto plano ni reversible.
|
- `users.password_hash` con bcrypt, nunca texto plano ni reversible.
|
||||||
- `users.status` (`pending_verification`/`pending_approval`/`active`) es el ciclo de vida del
|
- `users.status` (`pending_verification`/`pending_approval`/`active`/`invited`) es el ciclo de vida
|
||||||
autorregistro, independiente de `is_active` (que sigue siendo el interruptor manual del admin
|
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**
|
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).
|
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
|
- `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`).
|
`.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
|
- Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios **activos** con
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [1.6.1] - 2026-09-09
|
||||||
### Changed
|
### Changed
|
||||||
- Nuevo ícono/logo de la app derivado del ícono original de LinkedCorp (círculo azul de marca
|
- Nuevo ícono/logo de la app derivado del ícono original de LinkedCorp (círculo azul de marca
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "1.6.1",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ export const getVerifyEmail = asyncHandler(async (req, res) => {
|
|||||||
ok(res, result);
|
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) => {
|
export const postForgotPassword = asyncHandler(async (req, res) => {
|
||||||
const { email } = req.body || {};
|
const { email } = req.body || {};
|
||||||
if (email) await authService.forgotPassword(email);
|
if (email) await authService.forgotPassword(email);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import rateLimit from 'express-rate-limit';
|
|||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
import {
|
import {
|
||||||
postLogin, postLogout, getMe, postRegister, getVerifyEmail, postForgotPassword, postResetPassword,
|
postLogin, postLogout, getMe, postRegister, getVerifyEmail, postForgotPassword, postResetPassword,
|
||||||
putChangePassword,
|
putChangePassword, getInvite, postAcceptInvite,
|
||||||
} from './controller.js';
|
} from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -21,6 +21,8 @@ router.post('/register', loginLimiter, postRegister);
|
|||||||
router.get('/verify-email', loginLimiter, getVerifyEmail);
|
router.get('/verify-email', loginLimiter, getVerifyEmail);
|
||||||
router.post('/forgot-password', loginLimiter, postForgotPassword);
|
router.post('/forgot-password', loginLimiter, postForgotPassword);
|
||||||
router.post('/reset-password', loginLimiter, postResetPassword);
|
router.post('/reset-password', loginLimiter, postResetPassword);
|
||||||
|
router.get('/invite', loginLimiter, getInvite);
|
||||||
|
router.post('/accept-invite', loginLimiter, postAcceptInvite);
|
||||||
router.post('/logout', requireAuth, postLogout);
|
router.post('/logout', requireAuth, postLogout);
|
||||||
router.get('/me', requireAuth, getMe);
|
router.get('/me', requireAuth, getMe);
|
||||||
router.put('/change-password', requireAuth, loginLimiter, putChangePassword);
|
router.put('/change-password', requireAuth, loginLimiter, putChangePassword);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createNotification } from '../notifications/service.js';
|
|||||||
const STATUS_MESSAGES = {
|
const STATUS_MESSAGES = {
|
||||||
pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.',
|
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.',
|
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() {
|
function newToken() {
|
||||||
@@ -130,6 +131,57 @@ export async function verifyEmail(token) {
|
|||||||
return { email: user.email };
|
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) {
|
export async function forgotPassword(email) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT id, name FROM users WHERE email = ? AND status = 'active' AND is_active = 1`,
|
`SELECT id, name FROM users WHERE email = ? AND status = 'active' AND is_active = 1`,
|
||||||
|
|||||||
@@ -34,8 +34,14 @@ export const deleteProjectPermanent = asyncHandler(async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const postMember = 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);
|
const { userId, email, roleInProject } = req.body;
|
||||||
ok(res, members, 201);
|
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) => {
|
export const putMemberRole = asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
import * as usersService from '../users/service.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
import { ROLES } from '../../middleware/auth.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);
|
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);
|
const project = await repo.findById(projectId);
|
||||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||||
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
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 } });
|
let targetUserId = userId;
|
||||||
return members;
|
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) {
|
export async function setMemberAdmin(projectId, userId, isAdmin, currentUser) {
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ export async function findByEmail(email) {
|
|||||||
return rows[0] || null;
|
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 }) {
|
export async function create({ name, email, passwordHash, roleId, departmentId, position }) {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
`INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active)
|
`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 bcrypt from 'bcryptjs';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
import { logAudit } from '../../utils/audit.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 listUsers = () => repo.findAll();
|
||||||
export const listRoles = () => repo.listRoles();
|
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) {
|
export async function createUser(payload, actorId) {
|
||||||
if (!payload.name || !payload.email || !payload.password || !payload.roleId) {
|
if (!payload.name || !payload.email || !payload.password || !payload.roleId) {
|
||||||
throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios');
|
throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.6.1",
|
"version": "1.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import RegisterPage from '../pages/Login/RegisterPage.jsx';
|
|||||||
import VerifyEmailPage from '../pages/Login/VerifyEmailPage.jsx';
|
import VerifyEmailPage from '../pages/Login/VerifyEmailPage.jsx';
|
||||||
import ForgotPasswordPage from '../pages/Login/ForgotPasswordPage.jsx';
|
import ForgotPasswordPage from '../pages/Login/ForgotPasswordPage.jsx';
|
||||||
import ResetPasswordPage from '../pages/Login/ResetPasswordPage.jsx';
|
import ResetPasswordPage from '../pages/Login/ResetPasswordPage.jsx';
|
||||||
|
import AcceptInvitePage from '../pages/Login/AcceptInvitePage.jsx';
|
||||||
import DashboardPage from '../pages/Dashboard/DashboardPage.jsx';
|
import DashboardPage from '../pages/Dashboard/DashboardPage.jsx';
|
||||||
import OrganizacionPage from '../pages/Organizacion/OrganizacionPage.jsx';
|
import OrganizacionPage from '../pages/Organizacion/OrganizacionPage.jsx';
|
||||||
import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx';
|
import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx';
|
||||||
@@ -27,6 +28,7 @@ export default function App() {
|
|||||||
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
||||||
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||||
|
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
||||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="proyectos" element={<ProyectosPage />} />
|
<Route path="proyectos" element={<ProyectosPage />} />
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export const messages = {
|
|||||||
verify_email_title: 'Verificar correo',
|
verify_email_title: 'Verificar correo',
|
||||||
verify_email_success: 'Correo confirmado. Tu cuenta esta pendiente de aprobacion por un administrador.',
|
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.',
|
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_title: 'Recuperar clave',
|
||||||
forgot_password_submit: 'Enviar instrucciones',
|
forgot_password_submit: 'Enviar instrucciones',
|
||||||
forgot_password_sent: 'Si el correo existe, te enviamos instrucciones para recuperar tu clave.',
|
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_view: 'Ver',
|
||||||
common_yes: 'Si',
|
common_yes: 'Si',
|
||||||
common_no: 'No',
|
common_no: 'No',
|
||||||
|
common_or: 'o',
|
||||||
common_loading: 'Cargando...',
|
common_loading: 'Cargando...',
|
||||||
common_empty: 'No hay datos para mostrar',
|
common_empty: 'No hay datos para mostrar',
|
||||||
common_confirm_delete_title: 'Confirmar eliminacion',
|
common_confirm_delete_title: 'Confirmar eliminacion',
|
||||||
@@ -127,6 +132,9 @@ export const messages = {
|
|||||||
project_role_admin: 'Administrador',
|
project_role_admin: 'Administrador',
|
||||||
project_make_admin: 'Hacer administrador',
|
project_make_admin: 'Hacer administrador',
|
||||||
project_remove_admin: 'Quitar 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_created_at: 'Creado',
|
||||||
project_deleted_at: 'Eliminado',
|
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.',
|
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_title: 'Verify email',
|
||||||
verify_email_success: 'Email confirmed. Your account is pending administrator approval.',
|
verify_email_success: 'Email confirmed. Your account is pending administrator approval.',
|
||||||
verify_email_missing_token: 'Invalid link: missing verification token.',
|
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_title: 'Recover password',
|
||||||
forgot_password_submit: 'Send instructions',
|
forgot_password_submit: 'Send instructions',
|
||||||
forgot_password_sent: 'If the email exists, we sent instructions to recover your password.',
|
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_view: 'View',
|
||||||
common_yes: 'Yes',
|
common_yes: 'Yes',
|
||||||
common_no: 'No',
|
common_no: 'No',
|
||||||
|
common_or: 'or',
|
||||||
common_loading: 'Loading...',
|
common_loading: 'Loading...',
|
||||||
common_empty: 'No data to show',
|
common_empty: 'No data to show',
|
||||||
common_confirm_delete_title: 'Confirm delete',
|
common_confirm_delete_title: 'Confirm delete',
|
||||||
@@ -327,6 +340,9 @@ export const messages = {
|
|||||||
project_role_admin: 'Administrator',
|
project_role_admin: 'Administrator',
|
||||||
project_make_admin: 'Make administrator',
|
project_make_admin: 'Make administrator',
|
||||||
project_remove_admin: 'Remove 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_created_at: 'Created',
|
||||||
project_deleted_at: 'Deleted',
|
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.',
|
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.',
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="login-shell">
|
||||||
|
<form className="login-card" onSubmit={handleSubmit}>
|
||||||
|
<div className="login-logo">
|
||||||
|
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||||
|
</div>
|
||||||
|
<div className="login-title">{t('accept_invite_title')}</div>
|
||||||
|
{error && <div className="login-error">{error}</div>}
|
||||||
|
{state === 'loading' && <div className="text-muted">{t('common_loading')}</div>}
|
||||||
|
{state === 'ready' && (
|
||||||
|
<>
|
||||||
|
<div className="text-muted" style={{ fontSize: '.8rem', marginBottom: 10 }}>{t('accept_invite_hint')}</div>
|
||||||
|
<Field label={t('login_email')}>
|
||||||
|
<input className="input" value={email} disabled />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('register_name')}>
|
||||||
|
<input className="input" value={name} onChange={(e) => setName(e.target.value)} required autoFocus />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('login_password')}>
|
||||||
|
<PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('register_password_confirm')}>
|
||||||
|
<PasswordInput value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
<Button variant="accent" icon={UserPlus} type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center' }}>
|
||||||
|
{t('accept_invite_submit')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="login-links">
|
||||||
|
<Link to="/login">{t('register_back_to_login')}</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' } };
|
const ROLE_BADGE = { owner: { color: 'accent', key: 'project_role_owner' }, admin: { color: 'success', key: 'project_role_admin' } };
|
||||||
|
|
||||||
export default function ProjectMembersTab({ project, onChanged }) {
|
export default function ProjectMembersTab({ project, onChanged }) {
|
||||||
const { t, openDialog } = useUi();
|
const { t, openDialog, pushToast } = useUi();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: users } = useAsync(listUsers, []);
|
const { data: users } = useAsync(listUsers, []);
|
||||||
|
|
||||||
const [addingMember, setAddingMember] = useState('');
|
const [addingMember, setAddingMember] = useState('');
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
const [addAsAdmin, setAddAsAdmin] = useState(false);
|
const [addAsAdmin, setAddAsAdmin] = useState(false);
|
||||||
const [addMemberOpen, setAddMemberOpen] = 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 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() {
|
async function addMember() {
|
||||||
if (!addingMember) return;
|
if (!canAdd) return;
|
||||||
await runAction(async () => {
|
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('');
|
setAddingMember('');
|
||||||
|
setInviteEmail('');
|
||||||
setAddAsAdmin(false);
|
setAddAsAdmin(false);
|
||||||
setAddMemberOpen(false);
|
setAddMemberOpen(false);
|
||||||
onChanged();
|
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 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
{project.members.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={300}>
|
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={380}>
|
||||||
<Field label={t('project_add_member')}>
|
<Field label={t('project_add_member')}>
|
||||||
<ComboBox options={userOptions} value={addingMember} onChange={setAddingMember} placeholder={t('project_add_member')} />
|
<ComboBox
|
||||||
|
options={userOptions}
|
||||||
|
value={addingMember}
|
||||||
|
onChange={(v) => { setAddingMember(v); setInviteEmail(''); }}
|
||||||
|
placeholder={t('project_add_member')}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="text-muted" style={{ fontSize: '.76rem', margin: '8px 0', textAlign: 'center' }}>{t('common_or')}</div>
|
||||||
|
<Field label={t('project_invite_email')} hint={t('project_invite_email_hint')}>
|
||||||
|
<input
|
||||||
|
className="input" type="email" value={inviteEmail}
|
||||||
|
onChange={(e) => { setInviteEmail(e.target.value); setAddingMember(''); }}
|
||||||
|
placeholder="correo@dominio.com"
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<label className="checkbox-row">
|
<label className="checkbox-row">
|
||||||
<input type="checkbox" checked={addAsAdmin} onChange={(e) => setAddAsAdmin(e.target.checked)} />
|
<input type="checkbox" checked={addAsAdmin} onChange={(e) => setAddAsAdmin(e.target.checked)} />
|
||||||
{t('project_add_member_as_admin')}
|
{t('project_add_member_as_admin')}
|
||||||
</label>
|
</label>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
<Button variant="accent" icon={UserPlus} onClick={addMember}>{t('common_save')}</Button>
|
<Button variant="accent" icon={UserPlus} onClick={addMember} disabled={!canAdd}>{t('common_save')}</Button>
|
||||||
<Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button>
|
<Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
|
|||||||
@@ -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 forgotPassword = (email) => api.post('/auth/forgot-password', { email });
|
||||||
export const resetPassword = (token, password) => api.post('/auth/reset-password', { token, password });
|
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 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 });
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const updateProject = (id, data) => api.put(`/projects/${id}`, data);
|
|||||||
export const deleteProject = (id) => api.delete(`/projects/${id}`);
|
export const deleteProject = (id) => api.delete(`/projects/${id}`);
|
||||||
export const restoreProject = (id) => api.post(`/projects/${id}/restore`);
|
export const restoreProject = (id) => api.post(`/projects/${id}/restore`);
|
||||||
export const permanentlyDeleteProject = (id) => api.delete(`/projects/${id}/permanent`);
|
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 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 removeProjectMember = (id, userId) => api.delete(`/projects/${id}/members/${userId}`);
|
||||||
export const getGantt = (id) => api.get(`/projects/${id}/gantt`);
|
export const getGantt = (id) => api.get(`/projects/${id}/gantt`);
|
||||||
|
|||||||
Reference in New Issue
Block a user