feat: administradores por proyecto y Configuracion solo para superadmin

- 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 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-09 09:41:06 -05:00
parent e018eba8c1
commit f96391d360
16 changed files with 144 additions and 28 deletions
+5 -2
View File
@@ -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 (
<nav className={`nav-view ${mode}`}>
<div className="nav-header">
@@ -25,7 +28,7 @@ export function NavigationView({ mode }) {
)}
</div>
<div className="nav-items">
{ITEMS.map((item) => (
{items.map((item) => (
<NavLink key={item.to} to={item.to} end={item.end} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<item.icon />
{mode !== 'compact' && <span>{t(item.labelKey)}</span>}
+10
View File
@@ -122,6 +122,11 @@ export const messages = {
project_notes: 'Observaciones',
project_members: 'Miembros',
project_add_member: 'Agregar miembro',
project_add_member_as_admin: 'Agregar como administrador del proyecto',
project_role_owner: 'Dueño',
project_role_admin: 'Administrador',
project_make_admin: 'Hacer administrador',
project_remove_admin: 'Quitar administrador',
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.',
@@ -317,6 +322,11 @@ export const messages = {
project_notes: 'Notes',
project_members: 'Members',
project_add_member: 'Add member',
project_add_member_as_admin: 'Add as project administrator',
project_role_owner: 'Owner',
project_role_admin: 'Administrator',
project_make_admin: 'Make administrator',
project_remove_admin: 'Remove administrator',
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.',
@@ -1,3 +1,4 @@
import { Navigate } from 'react-router-dom';
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
@@ -11,6 +12,10 @@ export default function ConfiguracionPage() {
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
const { user } = useAuth();
// Regla dura: Configuracion es solo del superadmin (catalogos, SMTP...). El nav ya la oculta
// para los demas roles; esta guarda de ruta evita entrar tecleando /configuracion directo.
if (user?.roleCode !== 'superadmin') return <Navigate to="/" replace />;
return (
<div>
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('config_title')}</h2>
@@ -88,11 +93,9 @@ export default function ConfiguracionPage() {
/>
</Expander>
{user?.roleCode === 'superadmin' && (
<Expander icon={Mail} title={t('config_smtp')}>
<SmtpSettingsPanel />
</Expander>
)}
<Expander icon={Mail} title={t('config_smtp')}>
<SmtpSettingsPanel />
</Expander>
</div>
);
}
@@ -1,20 +1,24 @@
import { useState } from 'react';
import { UserPlus, Trash2 } from 'lucide-react';
import { UserPlus, Trash2, ShieldCheck, ShieldOff } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { addProjectMember, removeProjectMember } from '../../services/projects.js';
import { addProjectMember, setProjectMemberAdmin, 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' }, admin: { color: 'success', key: 'project_role_admin' } };
export default function ProjectMembersTab({ project, onChanged }) {
const { t, openDialog } = useUi();
const runAction = useAction();
const { data: users } = useAsync(listUsers, []);
const [addingMember, setAddingMember] = 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 }));
@@ -22,13 +26,21 @@ export default function ProjectMembersTab({ project, onChanged }) {
async function addMember() {
if (!addingMember) return;
await runAction(async () => {
await addProjectMember(project.id, Number(addingMember));
await addProjectMember(project.id, Number(addingMember), addAsAdmin ? 'admin' : null);
setAddingMember('');
setAddAsAdmin(false);
setAddMemberOpen(false);
onChanged();
});
}
function toggleAdmin(member) {
runAction(async () => {
await setProjectMemberAdmin(project.id, member.user_id, member.role_in_project !== 'admin');
onChanged();
});
}
function confirmRemoveMember(member) {
openDialog({
tone: 'warning',
@@ -48,19 +60,36 @@ export default function ProjectMembersTab({ project, onChanged }) {
<h3 style={{ margin: 0 }}>{t('project_members')}</h3>
<Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}</Button>
</div>
{project.members.map((m) => (
<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>
<button className="icon-btn" onClick={() => confirmRemoveMember(m)}><Trash2 size={16} /></button>
</div>
))}
{project.members.map((m) => {
const badge = ROLE_BADGE[m.role_in_project];
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>
)}
<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={260}>
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={300}>
<Field label={t('project_add_member')}>
<ComboBox options={userOptions} value={addingMember} onChange={setAddingMember} placeholder={t('project_add_member')} />
</Field>
<label className="checkbox-row">
<input type="checkbox" checked={addAsAdmin} onChange={(e) => setAddAsAdmin(e.target.checked)} />
{t('project_add_member_as_admin')}
</label>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={UserPlus} onClick={addMember}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button>
+1
View File
@@ -11,5 +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, roleInProject) => api.post(`/projects/${id}/members`, { userId, 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`);