Permiso vistas implementados

This commit is contained in:
2026-03-24 13:57:54 -06:00
parent 7b00f1336d
commit 94ca24e00e
15 changed files with 1037 additions and 405 deletions

View File

@@ -1,88 +1,60 @@
<script lang="ts">
import { page } from '$app/stores';
import { INTERNAL_ROUTES } from '$lib/routes-registry';
import { auth } from '$lib/stores/auth.js';
import { navPerms } from '$lib/stores/navPermissions';
import { onMount } from 'svelte';
export let open = false;
// Navigation items based on user role
$: navigation = getNavigationForRole($auth.user?.role);
const NAV_ICONS: Record<string, string> = {
'/': 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z',
'/tickets':
'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
'/users':
'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z',
'/tenants':
'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
'/categories':
'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
'/systems':
'M5 12a1 1 0 102 0V6.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L5 6.414V12zm14 0a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L19 17.586V12z',
'/sla': 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
'/audit':
'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
'/test-report':
'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
};
function getNavigationForRole(role: string | undefined) {
const baseNavigation = [
onMount(() => {
navPerms.load();
});
$: navigation = (() => {
const role = $auth.user?.role;
const isAdmin = role === 'ADMIN';
const items: { name: string; href: string; icon: string }[] = [
{
name: 'Dashboard',
href: '/',
icon: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z'
},
{
name: 'Tickets',
href: '/tickets',
icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2'
icon: NAV_ICONS['/']
}
];
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') {
baseNavigation.push(
{
name: 'Clientes',
href: '/tenants',
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4'
},
{
name: 'Usuarios',
href: '/users',
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z'
},
{
name: 'Categorías',
href: '/categories',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10'
},
{
name: 'Sistemas',
href: '/systems',
icon: 'M5 12a1 1 0 102 0V6.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L5 6.414V12zm14 0a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L19 17.586V12z'
}
);
}
INTERNAL_ROUTES.forEach(route => {
const hasPermission = isAdmin || $navPerms[route.key] === true;
if (hasPermission) {
items.push({
name: route.label,
href: route.path,
icon: NAV_ICONS[route.path] || NAV_ICONS['/tickets']
});
}
});
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') {
baseNavigation.push(
{
name: 'SLA Management',
href: '/sla',
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z'
},
{
name: 'Reportes',
href: '/reports',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
}
);
}
if (role === 'ADMIN') {
baseNavigation.push(
{
name: 'Auditoría',
href: '/audit',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z'
},
{
name: 'Seguridad',
href: '/audit/security',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
},
{
name: 'Reporte Endpoints',
href: '/test-report',
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18'
}
);
}
return baseNavigation;
}
return items;
})();
function isCurrentPage(href: string) {
return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/');
@@ -132,7 +104,6 @@
<p class="text-xs text-primary-100">Panel Interno</p>
</div>
</div>
<button
on:click={() => (open = false)}
class="p-2 rounded-md text-primary-100 hover:text-white hover:bg-primary-500 lg:hidden"

View File

@@ -0,0 +1,18 @@
export interface RouteDefinition {
key: string;
label: string;
path: string;
icon?: string;
adminOnly?: boolean;
}
export const INTERNAL_ROUTES: RouteDefinition[] = [
{ key: 'nav:tickets', label: 'Tickets', path: '/tickets' },
{ key: 'nav:users', label: 'Usuarios', path: '/users' },
{ key: 'nav:tenants', label: 'Clientes', path: '/tenants' },
{ key: 'nav:categories', label: 'Categorías', path: '/categories' },
{ key: 'nav:systems', label: 'Sistemas', path: '/systems' },
{ key: 'nav:sla', label: 'SLA', path: '/sla' },
{ key: 'nav:audit', label: 'Auditoría', path: '/audit' },
{ key: 'nav:reports', label: 'Reportes', path: '/test-report' },
]

View File

@@ -0,0 +1,21 @@
import { writable } from 'svelte/store';
import { api } from '$lib/utils/api';
function createNavPermissionsStore() {
const { subscribe, set } = writable<Record<string, boolean>>({});
return {
subscribe,
load: async () => {
try {
const perms = await api.get<Record<string, boolean>>('/permissions/my');
set(perms);
} catch (e) {
set({});
}
},
clear: () => set({})
};
}
export const navPerms = createNavPermissionsStore();

View File

@@ -1,14 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { auth } from '$lib/stores/auth';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
import { INTERNAL_ROUTES } from '$lib/routes-registry';
let users = [];
let tenants = [];
let isLoading = false;
let showModal = false;
let editingUser = null;
let navPermissions: Record<string, boolean> = {};
let isLoadingPerms = false;
let formData = {
email: '',
@@ -29,6 +33,12 @@
{ value: 'CLIENT_USER', label: 'Usuario Cliente' }
];
// Solo ADMIN y SUPPORT_MANAGER pueden gestionar permisos de navegación
$: canManageNav = $auth.user?.role === 'ADMIN' || $auth.user?.role === 'SUPPORT_MANAGER';
// Rutas relevantes según el rol del usuario que se está editando
$: relevantRoutes = INTERNAL_ROUTES;
async function loadData() {
isLoading = true;
try {
@@ -45,6 +55,30 @@
}
}
function initNavPermissions() {
// Inicializar todos en false por defecto
navPermissions = {};
INTERNAL_ROUTES.forEach(r => {
navPermissions[r.key] = false;
});
}
async function loadUserNavPermissions(userId: string) {
isLoadingPerms = true;
try {
const perms = await api.get(`/permissions/user/${userId}`);
// perms es un objeto { permission: granted }
INTERNAL_ROUTES.forEach(r => {
navPermissions[r.key] = perms[r.key] ?? false;
});
} catch (e) {
// Si falla, dejar defaults
initNavPermissions();
} finally {
isLoadingPerms = false;
}
}
function openCreateModal() {
editingUser = null;
formData = {
@@ -56,40 +90,61 @@
tenant_id: '',
is_active: true
};
initNavPermissions();
showModal = true;
}
function openEditModal(user) {
async function openEditModal(user) {
editingUser = user;
formData = {
email: user.email,
password: '', // Don't show password
password: '',
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
tenant_id: user.tenant_id || '',
is_active: user.is_active
};
initNavPermissions();
showModal = true;
if (canManageNav) {
await loadUserNavPermissions(user.id);
}
}
async function handleSubmit() {
try {
const payload = { ...formData };
if (!payload.password) delete payload.password; // Don't send empty password on edit
if (!payload.tenant_id) payload.tenant_id = null; // Send null if empty string
if (!payload.password) delete payload.password;
if (!payload.tenant_id) payload.tenant_id = null;
let userId: string;
if (editingUser) {
await api.put(`/users/${editingUser.id}`, payload);
userId = editingUser.id;
toast.success('Usuario actualizado');
} else {
if (!payload.password) {
toast.error('La contraseña es requerida para nuevos usuarios');
return;
toast.error('La contraseña es requerida para nuevos usuarios');
return;
}
await api.post('/users/', payload);
const newUser = await api.post('/users/', payload);
userId = newUser.id;
toast.success('Usuario creado');
}
// Guardar permisos de navegación
if (canManageNav && userId) {
try {
await api.put(`/permissions/user/${userId}`, {
permissions: navPermissions
});
} catch (e) {
toast.error('Usuario guardado pero error al guardar permisos de navegación');
}
}
showModal = false;
loadData();
} catch (e) {
@@ -130,20 +185,18 @@
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Usuario</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Rol</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Cliente (Tenant)</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Usuario</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Rol</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Cliente (Tenant)</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th class="relative py-3.5 pl-3 pr-4 sm:pr-6"><span class="sr-only">Acciones</span></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
{:else if users.length === 0}
<tr><td colspan="5" class="text-center py-4">No hay usuarios registrados</td></tr>
<tr><td colspan="5" class="text-center py-4">No hay usuarios registrados</td></tr>
{:else}
{#each users as user}
<tr>
@@ -177,27 +230,34 @@
<div class="grid grid-cols-2 gap-4">
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="first_name" bind:value={formData.first_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<input type="text" id="first_name" bind:value={formData.first_name} required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700">Apellido</label>
<input type="text" id="last_name" bind:value={formData.last_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<input type="text" id="last_name" bind:value={formData.last_name} required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" id="email" bind:value={formData.email} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<input type="email" id="email" bind:value={formData.email} required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Contraseña {editingUser ? '(dejar en blanco para mantener)' : ''}</label>
<input type="password" id="password" bind:value={formData.password} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<label for="password" class="block text-sm font-medium text-gray-700">
Contraseña {editingUser ? '(dejar en blanco para mantener)' : ''}
</label>
<input type="password" id="password" bind:value={formData.password}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
</div>
<div>
<label for="role" class="block text-sm font-medium text-gray-700">Rol</label>
<select id="role" bind:value={formData.role} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<select id="role" bind:value={formData.role}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
{#each ROLES as role}
<option value={role.value}>{role.label}</option>
{/each}
@@ -205,8 +265,9 @@
</div>
<div>
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - solo para usuarios externos)</label>
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional)</label>
<select id="tenant" bind:value={formData.tenant_id}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
<option value="">-- Ninguno (Usuario Interno) --</option>
{#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option>
@@ -215,17 +276,49 @@
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
<input type="checkbox" id="is_active" bind:checked={formData.is_active}
class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<!-- Sección de vistas — solo para admins -->
{#if canManageNav}
<div class="pt-4 border-t border-gray-200">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-900">Vistas disponibles</h4>
{#if isLoadingPerms}
<span class="text-xs text-gray-400">Cargando permisos...</span>
{/if}
</div>
<div class="grid grid-cols-2 gap-2">
{#each INTERNAL_ROUTES as route}
<label class="flex items-center gap-2 p-2 rounded-md border cursor-pointer hover:bg-gray-50
{navPermissions[route.key] ? 'border-blue-300 bg-blue-50' : 'border-gray-200'}">
<input
type="checkbox"
bind:checked={navPermissions[route.key]}
disabled={isLoadingPerms}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-gray-700">{route.label}</span>
</label>
{/each}
</div>
<p class="text-xs text-gray-400 mt-2">
Las vistas desactivadas no aparecerán en el menú del usuario.
</p>
</div>
{/if}
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
<button type="submit"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
<button type="button" on:click={() => showModal = false}
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>
</form>
</Modal>
</Modal>