Files
project.linkedcorp.net/frontend/src/pages/Proyectos/ProjectMembersTab.jsx
T
Milton Gonzalez fda83e9dff 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>
2026-09-25 18:19:41 -05:00

134 lines
6.0 KiB
React

import { useState } from '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, setProjectMemberRole, removeProjectMember } from '../../services/projects.js';
import { listUsers } from '../../services/users.js';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
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' },
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 [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;
await runAction(async () => {
const target = inviteEmail.trim()
? { email: inviteEmail.trim() }
: { userId: Number(addingMember) };
const result = await addProjectMember(project.id, target, newMemberRole === 'write' ? 'write' : null);
setAddingMember('');
setInviteEmail('');
setNewMemberRole('read');
setAddMemberOpen(false);
onChanged();
pushToast({ tone: 'success', message: result.invited ? t('project_invite_sent') : t('common_saved') });
});
}
function changeRole(member, role) {
runAction(async () => {
await setProjectMemberRole(project.id, member.user_id, role === 'write' ? 'write' : null);
onChanged();
});
}
function confirmRemoveMember(member) {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
message: t('common_confirm_delete_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { await removeProjectMember(project.id, member.user_id); onChanged(); }) },
],
});
}
return (
<div>
<div className="panel-card">
<div className="toolbar">
<h3 style={{ margin: 0 }}>{t('project_members')}</h3>
{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] || { 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>
{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>
)}
</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={420}>
<Field label={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={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>
</div>
</DetailPopup>
</div>
);
}