From f96391d3607a8d04c8c0345b6a0453b07559991d Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Wed, 9 Sep 2026 09:41:06 -0500 Subject: [PATCH] feat: administradores por proyecto y Configuracion solo para superadmin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Un proyecto puede tener uno o mas administradores (role_in_project= 'admin' en project_members), independiente del rol global del usuario. Nuevo PUT /api/projects/:id/members/:userId/role; canWriteProject lo reconoce ademas del comportamiento previo (admin/superadmin/it_manager global, o project_lead miembro). UI: casilla al agregar miembro + boton de escudo para promover/degradar (nunca toca al Dueño). - Menu "Configuracion" (apariencia, catalogos, SMTP) visible y accesible solo para el superadmin: oculto en el nav y con guarda en la ruta /configuracion para quien la teclee directo. Escritura de catalogos restringida de [superadmin, admin] a solo superadmin para que el backend sea consistente con la UI. Co-Authored-By: Claude Sonnet 5 --- API.md | 8 ++- BASE_DE_DATOS.md | 5 ++ CHANGELOG.md | 19 +++++++ VERSION | 2 +- backend/package.json | 2 +- backend/src/middleware/auth.js | 2 +- backend/src/modules/projects/controller.js | 5 ++ backend/src/modules/projects/repository.js | 19 +++++++ backend/src/modules/projects/routes.js | 3 +- backend/src/modules/projects/service.js | 25 ++++++++-- frontend/package.json | 2 +- frontend/src/components/NavigationView.jsx | 7 ++- frontend/src/i18n/messages.js | 10 ++++ .../pages/Configuracion/ConfiguracionPage.jsx | 13 +++-- .../src/pages/Proyectos/ProjectMembersTab.jsx | 49 +++++++++++++++---- frontend/src/services/projects.js | 1 + 16 files changed, 144 insertions(+), 28 deletions(-) diff --git a/API.md b/API.md index 91f48a2..8c577a2 100644 --- a/API.md +++ b/API.md @@ -53,7 +53,11 @@ 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` · `DELETE /api/projects/:id/members/:userId` +- `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`) · + `DELETE /api/projects/:id/members/:userId` ### Actividades (`/api/activities`) - `GET /api/projects/:projectId/activities` · `POST /api/projects/:projectId/activities` @@ -78,7 +82,7 @@ - `GET /api/dashboard/calendar?month=2026-09` — actividades por fecha límite - `GET /api/projects/:id/gantt` — actividades con fechas/avance para el Gantt -### Catálogos (`/api/catalogs`) — lectura general, escritura solo Administrador +### Catálogos (`/api/catalogs`) — lectura general, escritura solo Superadministrador - `GET/POST/PUT/DELETE /api/catalogs/project-statuses` - `GET/POST/PUT/DELETE /api/catalogs/activity-statuses` - `GET/POST/PUT/DELETE /api/catalogs/priorities` diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 90962ed..7bafcf5 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -71,6 +71,11 @@ "eliminarlo" — se oculta de listados/dashboard, sigue en BD). Borrado físico (`DELETE`, arrastra actividades/comentarios/adjuntos por `ON DELETE CASCADE`) solo vía `permanentlyDeleteProject`, solo superadmin, y solo sobre un proyecto que ya tiene `deleted_at` (dos pasos, nunca directo). +- `project_members.role_in_project`: `'owner'` (dueño, se asigna solo al crear el proyecto, nunca + se toca desde el endpoint de rol), `'admin'` (administrador **de ese proyecto puntual** — un + proyecto puede tener varios, otorgado por quien ya puede escribir el proyecto, sin importar el + rol global de a quién se lo otorgan), `NULL`/otro = miembro sin permisos de escritura (salvo que + además sea `project_lead` global, comportamiento previo sin cambios). - Empresa asumida única (no hay tabla `companies` en el MVP). - Producción siempre usa `192.168.222.32`. Desarrollo siempre usa `192.168.222.20`. No mezclar. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6527ca2..9ebc70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog - project.linkedcorp.net +## [1.6.0] - 2026-09-09 +### Added +- Un proyecto puede tener **uno o más administradores del proyecto** (`role_in_project = 'admin'` + en `project_members`), sin importar el rol global del usuario — un `collaborator` puede ser + administrador de un proyecto puntual. Se otorga/quita desde Miembros: casilla "Agregar como + administrador del proyecto" al agregar, y un botón de escudo para promover/degradar a un + miembro existente (protegido: nunca toca al `Dueño`). Nuevo `PUT + /api/projects/:id/members/:userId/role`. `canWriteProject` ahora reconoce este rol por-proyecto + además del comportamiento previo (admin/superadmin/it_manager global, o project_lead miembro). +### Changed +- El menú "Configuración" (apariencia, catálogos, SMTP) ahora es **visible y accesible solo para + el superadmin** — antes cualquier usuario logueado lo veía y podía entrar (el tema/idioma + personal sigue disponible para todos desde los íconos de la barra superior, eso no cambió). Se + guarda tanto en el menú lateral como en la ruta directa `/configuracion`. +- Escritura de catálogos (`/api/catalogs/*`, usado por la pantalla de Configuración) restringida + de `[superadmin, admin]` a solo `superadmin`, para que el backend sea consistente con que + `admin` ya no ve ni puede entrar a esa pantalla — la lectura sigue abierta a cualquier usuario + (los combos de prioridad/estado en el resto de la app la necesitan). + ## [1.5.0] - 2026-09-09 ### Added - Eliminar proyecto: eliminación **lógica** (`deleted_at`/`deleted_by`) disponible para quien puede diff --git a/VERSION b/VERSION index bc80560..dc1e644 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.0 +1.6.0 diff --git a/backend/package.json b/backend/package.json index 191f59b..e5b0ddf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.5.0", + "version": "1.6.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 899a59f..feb1126 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -54,7 +54,7 @@ export const ROLES = { VIEWER: 'viewer', }; -export const CAN_MANAGE_CATALOGS = [ROLES.SUPERADMIN, ROLES.ADMIN]; +export const CAN_MANAGE_CATALOGS = [ROLES.SUPERADMIN]; export const CAN_MANAGE_USERS = [ROLES.SUPERADMIN, ROLES.ADMIN]; export const CAN_MANAGE_SMTP = [ROLES.SUPERADMIN]; export const CAN_WRITE = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR]; diff --git a/backend/src/modules/projects/controller.js b/backend/src/modules/projects/controller.js index eadb473..93b5645 100644 --- a/backend/src/modules/projects/controller.js +++ b/backend/src/modules/projects/controller.js @@ -38,6 +38,11 @@ export const postMember = asyncHandler(async (req, res) => { ok(res, members, 201); }); +export const putMemberRole = asyncHandler(async (req, res) => { + const members = await service.setMemberAdmin(Number(req.params.id), Number(req.params.userId), !!req.body.admin, req.currentUser); + ok(res, members); +}); + export const deleteMember = asyncHandler(async (req, res) => { const members = await service.removeMember(Number(req.params.id), Number(req.params.userId), req.currentUser); ok(res, members); diff --git a/backend/src/modules/projects/repository.js b/backend/src/modules/projects/repository.js index c431170..55c2633 100644 --- a/backend/src/modules/projects/repository.js +++ b/backend/src/modules/projects/repository.js @@ -111,6 +111,15 @@ export async function isMember(projectId, userId) { return rows.length > 0; } +// null = no es miembro; 'member' = miembro sin rol especifico; 'admin'/'owner' = con ese rol. +export async function getMembership(projectId, userId) { + const [rows] = await pool.query('SELECT role_in_project FROM project_members WHERE project_id = ? AND user_id = ?', [ + projectId, userId, + ]); + if (!rows.length) return null; + return rows[0].role_in_project || 'member'; +} + export async function addMember(projectId, userId, roleInProject) { await pool.query( 'INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)', @@ -119,6 +128,16 @@ export async function addMember(projectId, userId, roleInProject) { return listMembers(projectId); } +// Nunca toca una fila con role_in_project = 'owner' (protege al dueño de perder ese rol por aqui). +export async function updateMemberRole(projectId, userId, roleInProject) { + await pool.query( + `UPDATE project_members SET role_in_project = ? + WHERE project_id = ? AND user_id = ? AND (role_in_project IS NULL OR role_in_project <> 'owner')`, + [roleInProject, projectId, userId] + ); + return listMembers(projectId); +} + export async function removeMember(projectId, userId) { await pool.query('DELETE FROM project_members WHERE project_id = ? AND user_id = ?', [projectId, userId]); return listMembers(projectId); diff --git a/backend/src/modules/projects/routes.js b/backend/src/modules/projects/routes.js index 232b176..0099873 100644 --- a/backend/src/modules/projects/routes.js +++ b/backend/src/modules/projects/routes.js @@ -2,7 +2,7 @@ import { Router } from 'express'; import { requireAuth } from '../../middleware/auth.js'; import { getProjects, getProject, postProject, putProject, deleteProject, postProjectRestore, - deleteProjectPermanent, postMember, deleteMember, + deleteProjectPermanent, postMember, putMemberRole, deleteMember, } from './controller.js'; import { getGantt } from '../dashboard/controller.js'; import { getActivitiesByProject, postActivity } from '../activities/controller.js'; @@ -19,6 +19,7 @@ router.post('/:id/restore', postProjectRestore); router.delete('/:id/permanent', deleteProjectPermanent); router.get('/:id/gantt', getGantt); router.post('/:id/members', postMember); +router.put('/:id/members/:userId/role', putMemberRole); router.delete('/:id/members/:userId', deleteMember); router.get('/:projectId/activities', getActivitiesByProject); router.post('/:projectId/activities', postActivity); diff --git a/backend/src/modules/projects/service.js b/backend/src/modules/projects/service.js index a60c25d..9f02c1d 100644 --- a/backend/src/modules/projects/service.js +++ b/backend/src/modules/projects/service.js @@ -3,12 +3,17 @@ import { HttpError } from '../../utils/http.js'; import { logAudit } from '../../utils/audit.js'; import { ROLES } from '../../middleware/auth.js'; -// Alcance de escritura: admin/it_manager siempre; project_lead solo si es dueño o miembro. +// Alcance de escritura: admin/superadmin/it_manager siempre; un proyecto puede tener uno o mas +// "administradores del proyecto" (role_in_project = 'owner'/'admin' en project_members) con acceso +// total sin importar su rol global — un collaborator puede ser admin de UN proyecto puntual; +// ademas, por compatibilidad, cualquier project_lead que sea miembro (con o sin ese tag) tambien +// puede escribir, igual que antes de introducir el rol de administrador por proyecto. export async function canWriteProject(user, project) { if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true; - if (user.roleCode !== ROLES.PROJECT_LEAD) return false; - if (project.owner_user_id === user.id) return true; - return repo.isMember(project.id, user.id); + const membership = await repo.getMembership(project.id, user.id); + if (membership === 'owner' || membership === 'admin') return true; + if (user.roleCode === ROLES.PROJECT_LEAD && membership !== null) return true; + return false; } // Solo el superadmin puede ver la papelera (proyectos eliminados logicamente). @@ -98,6 +103,18 @@ export async function addMember(projectId, userId, roleInProject, currentUser) { return members; } +export async function setMemberAdmin(projectId, userId, isAdmin, 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.updateMemberRole(projectId, userId, isAdmin ? 'admin' : null); + await logAudit({ + userId: currentUser.id, action: isAdmin ? 'grant_project_admin' : 'revoke_project_admin', + entityType: 'project', entityId: projectId, detail: { userId }, + }); + return members; +} + export async function removeMember(projectId, userId, currentUser) { const project = await repo.findById(projectId); if (!project) throw new HttpError(404, 'Proyecto no encontrado'); diff --git a/frontend/package.json b/frontend/package.json index 0fc8266..466af30 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.5.0", + "version": "1.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx index adba93a..690ca16 100644 --- a/frontend/src/components/NavigationView.jsx +++ b/frontend/src/components/NavigationView.jsx @@ -1,6 +1,7 @@ import { NavLink } from 'react-router-dom'; import { LayoutDashboard, FolderKanban, Building2, Settings } from 'lucide-react'; import { useUi } from '../context/ui-context.jsx'; +import { useAuth } from '../context/auth-context.jsx'; import logoDark from '../assets/logoLinkedCorpTxt.png'; import logoLight from '../assets/logoLinkedCorpTxtBlack.png'; import logoIcon from '../assets/logoLinked1.png'; @@ -9,12 +10,14 @@ const ITEMS = [ { to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true }, { to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' }, { to: '/organizacion', icon: Building2, labelKey: 'nav_organization' }, - { to: '/configuracion', icon: Settings, labelKey: 'nav_config' }, + { to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true }, ]; export function NavigationView({ mode }) { const { t, theme } = useUi(); + const { user } = useAuth(); const compact = mode === 'compact'; + const items = ITEMS.filter((item) => !item.superadminOnly || user?.roleCode === 'superadmin'); return (