feat: version 3.1.0 -- Acceso por modulo y por usuario
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import { BrandingProvider } from '../context/branding-context.jsx';
|
||||
import { ContentDialogHost } from '../components/ContentDialog.jsx';
|
||||
import { ToastHost } from '../components/ToastHost.jsx';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary.jsx';
|
||||
import { ProtectedRoute } from './ProtectedRoute.jsx';
|
||||
import { ProtectedRoute, ModuleRoute } from './ProtectedRoute.jsx';
|
||||
import AppShell from './AppShell.jsx';
|
||||
import LoginPage from '../pages/Login/LoginPage.jsx';
|
||||
import RegisterPage from '../pages/Login/RegisterPage.jsx';
|
||||
@@ -37,13 +37,13 @@ const router = createBrowserRouter(createRoutesFromElements(
|
||||
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="proyectos" element={<ProyectosPage />} />
|
||||
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
|
||||
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
|
||||
<Route path="notas" element={<NotasPage />} />
|
||||
<Route path="contrasenas" element={<ContrasenasPage />} />
|
||||
<Route path="equipos" element={<EquiposPage />} />
|
||||
<Route path="organizacion" element={<OrganizacionPage />} />
|
||||
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
|
||||
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
|
||||
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
|
||||
<Route path="notas" element={<ModuleRoute module="notes"><NotasPage /></ModuleRoute>} />
|
||||
<Route path="contrasenas" element={<ModuleRoute module="vault"><ContrasenasPage /></ModuleRoute>} />
|
||||
<Route path="equipos" element={<ModuleRoute module="devices"><EquiposPage /></ModuleRoute>} />
|
||||
<Route path="organizacion" element={<ModuleRoute module="organization"><OrganizacionPage /></ModuleRoute>} />
|
||||
<Route path="configuracion" element={<ConfiguracionPage />} />
|
||||
</Route>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/auth-context.jsx';
|
||||
import { hasModule } from '../utils/modules.js';
|
||||
|
||||
export function ProtectedRoute({ children }) {
|
||||
const { user, ready } = useAuth();
|
||||
@@ -9,3 +10,10 @@ export function ProtectedRoute({ children }) {
|
||||
if (!user) return <Navigate to="/login" replace state={{ from: `${location.pathname}${location.search}` }} />;
|
||||
return children;
|
||||
}
|
||||
|
||||
// Modulo deshabilitado para el usuario: al Panel (el backend igual responde 403).
|
||||
export function ModuleRoute({ module, children }) {
|
||||
const { user } = useAuth();
|
||||
if (!hasModule(user, module)) return <Navigate to="/" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -9,15 +9,16 @@ import { useBranding } from '../context/branding-context.jsx';
|
||||
import logoIcon from '../assets/logoLinkedTrackIcon.png';
|
||||
import { getDevicesAccess } from '../services/devices.js';
|
||||
import pkg from '../../package.json';
|
||||
import { hasModule } from '../utils/modules.js';
|
||||
|
||||
const ITEMS = [
|
||||
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
|
||||
{ to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' },
|
||||
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
||||
{ to: '/notas', icon: StickyNote, labelKey: 'nav_notes' },
|
||||
{ to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' },
|
||||
{ to: '/equipos', icon: Monitor, labelKey: 'nav_devices', needsDevicesAccess: true },
|
||||
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
|
||||
{ to: '/proyectos', module: 'projects', icon: FolderKanban, labelKey: 'nav_projects' },
|
||||
{ to: '/tareas-recurrentes', module: 'recurring_tasks', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
||||
{ to: '/notas', module: 'notes', icon: StickyNote, labelKey: 'nav_notes' },
|
||||
{ to: '/contrasenas', module: 'vault', icon: KeyRound, labelKey: 'nav_passwords' },
|
||||
{ to: '/equipos', module: 'devices', icon: Monitor, labelKey: 'nav_devices', needsDevicesAccess: true },
|
||||
{ to: '/organizacion', module: 'organization', icon: Building2, labelKey: 'nav_organization' },
|
||||
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
|
||||
];
|
||||
|
||||
@@ -34,7 +35,7 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) {
|
||||
if (!user) return;
|
||||
getDevicesAccess().then((a) => setCanSeeDevices(!!a?.canView)).catch(() => setCanSeeDevices(false));
|
||||
}, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.needsDevicesAccess || canSeeDevices));
|
||||
const items = ITEMS.filter((item) => hasModule(user, item.module) && (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.needsDevicesAccess || canSeeDevices));
|
||||
const brandName = branding.name || t('appName');
|
||||
// Menus con borradores pendientes (el borrador guarda la ruta donde se estaba editando).
|
||||
const drafts = useUserDrafts();
|
||||
|
||||
@@ -2,6 +2,9 @@ export const messages = {
|
||||
'es-EC': {
|
||||
appName: 'LinkedCorp Proyectos',
|
||||
nav_dashboard: 'Panel',
|
||||
user_modules: 'Módulos',
|
||||
user_modules_hint: 'Marca los módulos que este usuario puede usar. Lo que no esté marcado no aparece en su menú y el sistema le niega el acceso.',
|
||||
user_modules_saved: 'Módulos actualizados. El usuario verá el cambio al recargar.',
|
||||
nav_projects: 'Proyectos',
|
||||
nav_organization: 'Organizacion',
|
||||
nav_config: 'Configuracion',
|
||||
@@ -521,6 +524,9 @@ export const messages = {
|
||||
'en-US': {
|
||||
appName: 'LinkedCorp Projects',
|
||||
nav_dashboard: 'Dashboard',
|
||||
user_modules: 'Modules',
|
||||
user_modules_hint: 'Check the modules this user can use. Unchecked modules are hidden from their menu and access is denied.',
|
||||
user_modules_saved: 'Modules updated. The user will see the change after reloading.',
|
||||
nav_projects: 'Projects',
|
||||
nav_organization: 'Organization',
|
||||
nav_config: 'Settings',
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { getUserModules, setUserModules } from '../../services/users.js';
|
||||
import { MODULE_LABEL_KEYS } from '../../utils/modules.js';
|
||||
|
||||
// Acceso por modulo de un usuario (solo superadmin). Sin marcar = el usuario no ve el modulo.
|
||||
export function UserModulesDialog({ user, onClose }) {
|
||||
const { t, pushToast } = useUi();
|
||||
const [all, setAll] = useState([]);
|
||||
const [initial, setInitial] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
getUserModules(user.id).then((r) => { setAll(r.all); setInitial(r.modules); setSelected(r.modules); }).catch(() => {});
|
||||
}, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const dirty = all.some((m) => initial.includes(m) !== selected.includes(m));
|
||||
|
||||
function toggle(module) {
|
||||
setSelected((s) => (s.includes(module) ? s.filter((m) => m !== module) : [...s, module]));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!dirty) return onClose();
|
||||
setSaving(true);
|
||||
try {
|
||||
await setUserModules(user.id, selected);
|
||||
pushToast({ tone: 'success', message: t('user_modules_saved') });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
pushToast({ tone: 'error', message: err.message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailPopup open={!!user} title={`${t('user_modules')} — ${user?.name || ''}`} onClose={onClose} width={420} height={420}>
|
||||
<p className="text-muted" style={{ marginTop: 0 }}>{t('user_modules_hint')}</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 16 }}>
|
||||
{all.map((m) => (
|
||||
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={selected.includes(m)} onChange={() => toggle(m)} />
|
||||
{t(MODULE_LABEL_KEYS[m])}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="accent" icon={Save} onClick={save} disabled={saving || !dirty}>{t('common_save')}</Button>
|
||||
<Button variant="standard" onClick={onClose}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Save, UserX, UserCheck, Check, X as XIcon, Mail } from 'lucide-react';
|
||||
import { Plus, Save, UserX, UserCheck, Check, X as XIcon, Mail, LayoutGrid } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
@@ -12,6 +12,7 @@ import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import { UserModulesDialog } from './UserModulesDialog.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||
@@ -34,6 +35,8 @@ export default function UsersPanel() {
|
||||
const { data: deptTree } = useAsync(listDepartments, []);
|
||||
|
||||
const canManage = ['admin', 'superadmin'].includes(currentUser?.roleCode);
|
||||
const isSuperadmin = currentUser?.roleCode === 'superadmin';
|
||||
const [modulesUser, setModulesUser] = useState(null);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -179,6 +182,7 @@ export default function UsersPanel() {
|
||||
}
|
||||
return [
|
||||
{ label: t('common_edit'), onClick: () => startEdit(u) },
|
||||
...(isSuperadmin && u.role_code !== 'superadmin' ? [{ label: t('user_modules'), icon: LayoutGrid, onClick: () => setModulesUser(u) }] : []),
|
||||
u.is_active
|
||||
? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) }
|
||||
: { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) },
|
||||
@@ -186,6 +190,8 @@ export default function UsersPanel() {
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
{modulesUser && <UserModulesDialog user={modulesUser} onClose={() => setModulesUser(null)} />}
|
||||
|
||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
|
||||
<DraftNotice draft={draft} />
|
||||
<FieldRow>
|
||||
|
||||
@@ -8,3 +8,5 @@ export const setUserActive = (id, isActive) => api.patch(`/users/${id}/active`,
|
||||
export const approveUser = (id, data) => api.post(`/users/${id}/approve`, data);
|
||||
export const rejectUser = (id) => api.post(`/users/${id}/reject`);
|
||||
export const resendUserEmail = (id) => api.post(`/users/${id}/resend`);
|
||||
export const getUserModules = (id) => api.get(`/users/${id}/modules`);
|
||||
export const setUserModules = (id, modules) => api.put(`/users/${id}/modules`, { modules });
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Espejo de backend/src/middleware/modules.js: el backend es quien bloquea, aqui solo se oculta.
|
||||
export const MODULE_LABEL_KEYS = {
|
||||
projects: 'nav_projects',
|
||||
recurring_tasks: 'nav_recurring_tasks',
|
||||
notes: 'nav_notes',
|
||||
vault: 'nav_passwords',
|
||||
devices: 'nav_devices',
|
||||
organization: 'nav_organization',
|
||||
};
|
||||
|
||||
export function hasModule(user, module) {
|
||||
return !module || !!user?.modules?.includes(module);
|
||||
}
|
||||
Reference in New Issue
Block a user