fix: version 2.5.0 -- compartir proyectos con permisos reales (Lectura/Escritura)

Bug de seguridad real corregido: cualquier usuario autenticado veia
TODOS los proyectos (listado, detalle, actividades, tareas,
comentarios, adjuntos, KPIs del panel), sin importar si era miembro.
Ahora nadie ve un proyecto ajeno sin que su dueno se lo comparta --
los roles de administracion (superadmin/admin/it_manager) mantienen
visibilidad total para soporte.

Nuevo modelo de permisos por proyecto, simple y no granular:
- Solo el dueno original puede compartir un proyecto (agregar/quitar
  miembros, cambiar su nivel) -- ni un admin del proyecto ni un rol
  global pueden hacerlo por el.
- Dos niveles: Lectura (solo ver) y Escritura (crear/editar/eliminar
  actividades y tareas -- nunca el proyecto en si: no lo borra, no
  edita sus datos, no crea proyectos nuevos).

Actividades y tareas pasan a borrado logico (antes se borraban
fisico sin poder recuperar) -- nueva papelera por proyecto con
restaurar.

Incluye tambien el filtro "Usuario" en Proyectos (probado en dev en
un paso anterior), que va de la mano con este cambio de visibilidad.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-25 18:19:41 -05:00
parent 9b20f85ae0
commit fda83e9dff
30 changed files with 469 additions and 140 deletions
+20 -10
View File
@@ -168,12 +168,12 @@ export const messages = {
project_risk: 'Riesgo',
project_notes: 'Observaciones',
project_members: 'Miembros',
project_add_member: 'Agregar miembro',
project_add_member_as_admin: 'Agregar como administrador del proyecto',
project_members_owner_only_hint: 'Solo el dueño del proyecto puede compartirlo y cambiar permisos.',
project_add_member: 'Compartir proyecto',
project_add_member_role: 'Permiso',
project_role_owner: 'Dueño',
project_role_admin: 'Administrador',
project_make_admin: 'Hacer administrador',
project_remove_admin: 'Quitar administrador',
project_role_read: 'Lectura',
project_role_write: 'Escritura',
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',
@@ -215,6 +215,11 @@ export const messages = {
project_trash_title: 'Papelera de proyectos',
project_trash_back: 'Volver a proyectos',
project_trash_empty_hint: 'Los proyectos eliminados aparecen aqui hasta que se restauran o se borran definitivamente.',
common_restore: 'Restaurar',
activities_trash: 'Papelera de actividades',
activities_trash_empty: 'No hay actividades eliminadas.',
tasks_trash: 'Papelera de tareas',
tasks_trash_empty: 'No hay tareas eliminadas.',
project_restore: 'Restaurar',
project_delete_permanent: 'Eliminar definitivamente',
project_delete_permanent_confirm_msg: 'Esta accion NO se puede deshacer: se borra el proyecto y todas sus actividades, comentarios y archivos adjuntos de forma permanente.',
@@ -461,12 +466,12 @@ export const messages = {
project_risk: 'Risk',
project_notes: 'Notes',
project_members: 'Members',
project_add_member: 'Add member',
project_add_member_as_admin: 'Add as project administrator',
project_members_owner_only_hint: 'Only the project owner can share it and change permissions.',
project_add_member: 'Share project',
project_add_member_role: 'Permission',
project_role_owner: 'Owner',
project_role_admin: 'Administrator',
project_make_admin: 'Make administrator',
project_remove_admin: 'Remove administrator',
project_role_read: 'Read',
project_role_write: 'Write',
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',
@@ -508,6 +513,11 @@ export const messages = {
project_trash_title: 'Project trash',
project_trash_back: 'Back to projects',
project_trash_empty_hint: 'Deleted projects show up here until they are restored or permanently deleted.',
common_restore: 'Restore',
activities_trash: 'Deleted activities',
activities_trash_empty: 'No deleted activities.',
tasks_trash: 'Deleted tasks',
tasks_trash_empty: 'No deleted tasks.',
project_restore: 'Restore',
project_delete_permanent: 'Delete permanently',
project_delete_permanent_confirm_msg: 'This CANNOT be undone: the project and all its activities, comments and attachments will be permanently deleted.',
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Save, Trash2, ArrowRightLeft, FolderUp } from 'lucide-react';
import { Plus, Save, Trash2, ArrowRightLeft, FolderUp, Archive, RotateCcw } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity, convertActivityToProject } from '../../services/activities.js';
import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity, convertActivityToProject, listActivitiesTrash, restoreActivity } from '../../services/activities.js';
import { listProjects } from '../../services/projects.js';
import { search as searchApi } from '../../services/search.js';
import { listCatalog } from '../../services/catalogs.js';
@@ -23,12 +23,25 @@ import ActivityDetailPanel from './ActivityDetailPanel.jsx';
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
export default function ProjectActivitiesTab({ projectId, openActivityId, openActivityTab }) {
export default function ProjectActivitiesTab({ project, projectId, openActivityId, openActivityTab }) {
const { t, pushToast, openDialog } = useUi();
const { user: currentUser } = useAuth();
const navigate = useNavigate();
const runAction = useAction();
const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
const [trashOpen, setTrashOpen] = useState(false);
const { data: trashed, reload: reloadTrash } = useAsync(
() => (trashOpen ? listActivitiesTrash(projectId) : Promise.resolve([])),
[trashOpen, projectId]
);
function restoreFromTrash(activity) {
runAction(async () => {
await restoreActivity(activity.id);
await reloadTrash();
reload();
pushToast({ tone: 'success', message: t('common_saved') });
});
}
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
const { data: users } = useAsync(listUsers, []);
@@ -213,9 +226,22 @@ export default function ProjectActivitiesTab({ projectId, openActivityId, openAc
<SearchResultsDropdown items={searchItems} emptyLabel={t('common_search_no_results')} />
)}
</div>
{project?.isOwner && (
<Button variant="standard" icon={Archive} onClick={() => setTrashOpen(true)}>{t('activities_trash')}</Button>
)}
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button>
</div>
<DetailPopup open={trashOpen} title={t('activities_trash')} onClose={() => setTrashOpen(false)} width={480} height={420}>
{(trashed || []).length === 0 && <div className="text-muted" style={{ fontSize: '.82rem' }}>{t('activities_trash_empty')}</div>}
{(trashed || []).map((a) => (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0', borderBottom: '1px solid rgb(var(--line))' }}>
<span style={{ flex: 1, fontSize: '.82rem' }}>{a.code} — {a.name}</span>
<Button variant="standard" icon={RotateCcw} onClick={() => restoreFromTrash(a)}>{t('common_restore')}</Button>
</div>
))}
</DetailPopup>
<ListView
columns={columns}
rows={activities}
@@ -1,8 +1,9 @@
import { useState } from 'react';
import { UserPlus, Trash2, ShieldCheck, ShieldOff } from 'lucide-react';
import { UserPlus, Trash2 } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { addProjectMember, setProjectMemberAdmin, removeProjectMember } from '../../services/projects.js';
import { addProjectMember, setProjectMemberRole, removeProjectMember } from '../../services/projects.js';
import { listUsers } from '../../services/users.js';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
@@ -10,20 +11,29 @@ import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
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' },
write: { color: 'success', key: 'project_role_write' },
};
export default function ProjectMembersTab({ project, onChanged }) {
const { t, openDialog, pushToast } = useUi();
const { user: currentUser } = useAuth();
const runAction = useAction();
const { data: users } = useAsync(listUsers, []);
const [addingMember, setAddingMember] = useState('');
const [inviteEmail, setInviteEmail] = useState('');
const [addAsAdmin, setAddAsAdmin] = useState(false);
const [newMemberRole, setNewMemberRole] = useState('read');
const [addMemberOpen, setAddMemberOpen] = useState(false);
// Compartir (agregar/quitar/cambiar permiso) es exclusivo del dueño del proyecto -- el resto
// solo ve la lista de con quien esta compartido.
const canManage = !!project.isOwner || currentUser?.id === project.owner_user_id;
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();
const roleOptions = [{ value: 'read', label: t('project_role_read') }, { value: 'write', label: t('project_role_write') }];
async function addMember() {
if (!canAdd) return;
@@ -31,19 +41,19 @@ export default function ProjectMembersTab({ project, onChanged }) {
const target = inviteEmail.trim()
? { email: inviteEmail.trim() }
: { userId: Number(addingMember) };
const result = await addProjectMember(project.id, target, addAsAdmin ? 'admin' : null);
const result = await addProjectMember(project.id, target, newMemberRole === 'write' ? 'write' : null);
setAddingMember('');
setInviteEmail('');
setAddAsAdmin(false);
setNewMemberRole('read');
setAddMemberOpen(false);
onChanged();
pushToast({ tone: 'success', message: result.invited ? t('project_invite_sent') : t('common_saved') });
});
}
function toggleAdmin(member) {
function changeRole(member, role) {
runAction(async () => {
await setProjectMemberAdmin(project.id, member.user_id, member.role_in_project !== 'admin');
await setProjectMemberRole(project.id, member.user_id, role === 'write' ? 'write' : null);
onChanged();
});
}
@@ -65,31 +75,35 @@ export default function ProjectMembersTab({ project, onChanged }) {
<div className="panel-card">
<div className="toolbar">
<h3 style={{ margin: 0 }}>{t('project_members')}</h3>
<Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}</Button>
{canManage && <Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}</Button>}
</div>
{!canManage && <div className="text-muted" style={{ fontSize: '.76rem', marginBottom: 10 }}>{t('project_members_owner_only_hint')}</div>}
{project.members.map((m) => {
const badge = ROLE_BADGE[m.role_in_project];
const badge = ROLE_BADGE[m.role_in_project] || { color: 'muted', key: 'project_role_read' };
return (
<div key={m.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
<span style={{ flex: 1, fontSize: '.82rem' }}>{m.name} <span className="text-muted">· {m.email}</span></span>
{badge && <Badge color={badge.color}>{t(badge.key)}</Badge>}
{m.role_in_project !== 'owner' && (
<button
className="icon-btn"
title={m.role_in_project === 'admin' ? t('project_remove_admin') : t('project_make_admin')}
onClick={() => toggleAdmin(m)}
>
{m.role_in_project === 'admin' ? <ShieldOff size={16} /> : <ShieldCheck size={16} />}
</button>
{canManage && m.role_in_project !== 'owner' ? (
<div style={{ width: 130 }}>
<ComboBox
options={roleOptions}
value={m.role_in_project === 'write' ? 'write' : 'read'}
onChange={(v) => changeRole(m, v)}
/>
</div>
) : (
<Badge color={badge.color}>{t(badge.key)}</Badge>
)}
{canManage && m.role_in_project !== 'owner' && (
<button className="icon-btn" onClick={() => confirmRemoveMember(m)}><Trash2 size={16} /></button>
)}
<button className="icon-btn" onClick={() => confirmRemoveMember(m)}><Trash2 size={16} /></button>
</div>
);
})}
{project.members.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
</div>
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={380}>
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={420}>
<Field label={t('project_add_member')}>
<ComboBox
options={userOptions}
@@ -106,10 +120,9 @@ export default function ProjectMembersTab({ project, onChanged }) {
placeholder="correo@dominio.com"
/>
</Field>
<label className="checkbox-row">
<input type="checkbox" checked={addAsAdmin} onChange={(e) => setAddAsAdmin(e.target.checked)} />
{t('project_add_member_as_admin')}
</label>
<Field label={t('project_add_member_role')}>
<ComboBox options={roleOptions} value={newMemberRole} onChange={setNewMemberRole} />
</Field>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={UserPlus} onClick={addMember} disabled={!canAdd}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button>
@@ -58,7 +58,7 @@ export default function ProyectoDetallePage() {
{tab === 'info' && <ProjectInfoTab project={project} onChanged={reload} />}
{tab === 'members' && <ProjectMembersTab project={project} onChanged={reload} />}
{tab === 'activities' && (
<ProjectActivitiesTab projectId={projectId} openActivityId={deepLinkActivityId} openActivityTab={deepLinkActivityTab} />
<ProjectActivitiesTab project={project} projectId={projectId} openActivityId={deepLinkActivityId} openActivityTab={deepLinkActivityTab} />
)}
{tab === 'gantt' && <ProjectGanttTab projectId={projectId} />}
{tab === 'calendar' && <ProjectCalendarTab projectId={projectId} />}
+31 -6
View File
@@ -6,6 +6,7 @@ import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
import { listProjects, createProject, deleteProject, restoreProject, permanentlyDeleteProject } from '../../services/projects.js';
import { listUsers } from '../../services/users.js';
import { search as searchApi } from '../../services/search.js';
import { listCatalog } from '../../services/catalogs.js';
import { listDepartments } from '../../services/departments.js';
@@ -46,11 +47,16 @@ export default function ProyectosPage() {
const [filterStatusId, setFilterStatusId] = useState('');
const [filterPriorityId, setFilterPriorityId] = useState('');
const [filterDueSoon, setFilterDueSoon] = useState(false);
const [filterOwnerUserId, setFilterOwnerUserId] = useState('');
// Prueba exploratoria: filtro "ver todos / por usuario" solo para roles que ya ven todos los
// proyectos hoy (superadmin/admin/it_manager) -- backend ya soporta ownerUserId, sin cambios.
const canFilterByUser = ['superadmin', 'admin', 'it_manager'].includes(currentUser?.roleCode);
const { data: projects, reload } = useAsync(
() => listProjects(showTrash ? { trash: true } : { statusId: filterStatusId, priorityId: filterPriorityId }),
[showTrash, filterStatusId, filterPriorityId]
() => listProjects(showTrash ? { trash: true } : { statusId: filterStatusId, priorityId: filterPriorityId, ownerUserId: filterOwnerUserId || undefined }),
[showTrash, filterStatusId, filterPriorityId, filterOwnerUserId]
);
const { data: allUsers } = useAsync(listUsers, []);
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
const { data: types } = useAsync(() => listCatalog('project-types'), []);
@@ -207,12 +213,31 @@ export default function ProyectosPage() {
</div>
{!showTrash && (
<>
<div style={{ width: 160 }}>
<ComboBox options={[{ value: '', label: t('common_all') }, ...statusOptions]} value={filterStatusId} onChange={setFilterStatusId} placeholder={t('common_status')} />
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="text-muted" style={{ fontSize: '.72rem', whiteSpace: 'nowrap' }}>{t('common_status')}</span>
<div style={{ width: 160 }}>
<ComboBox options={[{ value: '', label: t('common_all') }, ...statusOptions]} value={filterStatusId} onChange={setFilterStatusId} placeholder={t('common_status')} />
</div>
</div>
<div style={{ width: 160 }}>
<ComboBox options={[{ value: '', label: t('common_all') }, ...priorityOptions]} value={filterPriorityId} onChange={setFilterPriorityId} placeholder={t('common_priority')} />
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="text-muted" style={{ fontSize: '.72rem', whiteSpace: 'nowrap' }}>{t('common_priority')}</span>
<div style={{ width: 160 }}>
<ComboBox options={[{ value: '', label: t('common_all') }, ...priorityOptions]} value={filterPriorityId} onChange={setFilterPriorityId} placeholder={t('common_priority')} />
</div>
</div>
{canFilterByUser && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="text-muted" style={{ fontSize: '.72rem', whiteSpace: 'nowrap' }}>Usuario</span>
<div style={{ width: 200 }}>
<ComboBox
options={[{ value: '', label: t('common_all') }, ...(allUsers || []).map((u) => ({ value: u.id, label: u.name }))]}
value={filterOwnerUserId}
onChange={setFilterOwnerUserId}
placeholder="Usuario"
/>
</div>
</div>
)}
<label className="checkbox-row">
<input type="checkbox" checked={filterDueSoon} onChange={(e) => setFilterDueSoon(e.target.checked)} />
{t('project_filter_due_soon')}
+2
View File
@@ -5,6 +5,8 @@ export const createActivity = (projectId, data) => api.post(`/projects/${project
export const getActivity = (id) => api.get(`/activities/${id}`);
export const updateActivity = (id, data) => api.put(`/activities/${id}`, data);
export const deleteActivity = (id) => api.delete(`/activities/${id}`);
export const restoreActivity = (id) => api.post(`/activities/${id}/restore`);
export const listActivitiesTrash = (projectId) => api.get(`/projects/${projectId}/activities/trash`);
export const moveActivity = (id, projectId) => api.put(`/activities/${id}/move`, { projectId });
export const convertActivityToProject = (id, projectName, taskActivityName) =>
api.post(`/activities/${id}/convert-to-project`, { projectName, taskActivityName });
+1 -1
View File
@@ -11,6 +11,6 @@ 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, 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 setProjectMemberRole = (id, userId, role) => api.put(`/projects/${id}/members/${userId}/role`, { role });
export const removeProjectMember = (id, userId) => api.delete(`/projects/${id}/members/${userId}`);
export const getGantt = (id) => api.get(`/projects/${id}/gantt`);
+2
View File
@@ -5,4 +5,6 @@ export const createTask = (activityId, data) => api.post(`/activities/${activity
export const updateTask = (id, data) => api.put(`/tasks/${id}`, data);
export const setTaskDone = (id, isDone) => api.patch(`/tasks/${id}/done`, { isDone });
export const deleteTask = (id) => api.delete(`/tasks/${id}`);
export const restoreTask = (id) => api.post(`/tasks/${id}/restore`);
export const listTasksTrash = (activityId) => api.get(`/activities/${activityId}/tasks/trash`);
export const reorderTasks = (activityId, ids) => api.put(`/activities/${activityId}/tasks/reorder`, { ids });