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

@@ -0,0 +1,130 @@
"""Permissions endpoints - ServiceManagerWeb"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from typing import Dict
import uuid
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User, UserRole
from app.models.permission import TenantPermission, DEFAULT_PERMISSIONS, CLIENT_PERMISSIONS
router = APIRouter()
@router.get("/my")
async def get_my_permissions(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Dict[str, bool]:
"""Obtener permisos efectivos del usuario actual."""
if current_user.role.is_global:
# Staff interno — devolver permisos del rol por defecto
role_defaults = DEFAULT_PERMISSIONS.get(current_user.role.value, {})
return role_defaults
# Buscar overrides individuales primero
result = await db.execute(
select(TenantPermission).where(
TenantPermission.tenant_id == current_user.tenant_id,
TenantPermission.user_id == current_user.id,
)
)
user_overrides = {p.permission: p.granted for p in result.scalars().all()}
# Buscar defaults del rol
result = await db.execute(
select(TenantPermission).where(
TenantPermission.tenant_id == current_user.tenant_id,
TenantPermission.user_id == None,
TenantPermission.role == current_user.role.value,
)
)
role_defaults = {p.permission: p.granted for p in result.scalars().all()}
# Merge: override individual > default del rol > system default
system_defaults = DEFAULT_PERMISSIONS.get(current_user.role.value, {})
effective = {**system_defaults, **role_defaults, **user_overrides}
return effective
@router.get("/user/{user_id}")
async def get_user_permissions(
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Dict[str, bool]:
"""Obtener permisos efectivos de un usuario específico."""
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.CLIENT_ADMIN):
raise HTTPException(status_code=403, detail="Sin permisos")
target = await db.get(User, user_id)
if not target:
raise HTTPException(status_code=404, detail="Usuario no encontrado")
# Buscar overrides individuales
result = await db.execute(
select(TenantPermission).where(
TenantPermission.user_id == user_id,
)
)
user_overrides = {p.permission: p.granted for p in result.scalars().all()}
# Buscar defaults del rol en el tenant
result = await db.execute(
select(TenantPermission).where(
TenantPermission.tenant_id == target.tenant_id,
TenantPermission.user_id == None,
TenantPermission.role == target.role.value,
)
)
role_defaults = {p.permission: p.granted for p in result.scalars().all()}
system_defaults = DEFAULT_PERMISSIONS.get(target.role.value, {})
effective = {**system_defaults, **role_defaults, **user_overrides}
return effective
@router.put("/user/{user_id}")
async def update_user_permissions(
user_id: uuid.UUID,
data: Dict[str, bool],
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Actualizar permisos individuales de un usuario.
Solo ADMIN, SUPPORT_MANAGER o CLIENT_ADMIN pueden hacerlo.
"""
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.CLIENT_ADMIN):
raise HTTPException(status_code=403, detail="Sin permisos")
target = await db.get(User, user_id)
if not target:
raise HTTPException(status_code=404, detail="Usuario no encontrado")
# Borrar overrides existentes del usuario
await db.execute(
delete(TenantPermission).where(
TenantPermission.user_id == user_id,
)
)
# Insertar nuevos overrides
from datetime import datetime
now = datetime.utcnow()
for permission, granted in data.items():
db.add(TenantPermission(
id=uuid.uuid4(),
tenant_id=target.tenant_id,
user_id=user_id,
role=target.role.value,
permission=permission,
granted=granted,
created_at=now,
updated_at=now,
))
await db.commit()
return {"message": "Permisos actualizados"}

View File

@@ -6,7 +6,7 @@ Router principal para la API v1
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants, permissions
api_router = APIRouter()
@@ -87,3 +87,10 @@ api_router.include_router(
prefix="/reports",
tags=["reports"]
)
# Permissions routes
api_router.include_router(
permissions.router,
prefix="/permissions",
tags=["permissions"]
)

View File

@@ -14,24 +14,99 @@ from app.core.database import Base, GUID
CLIENT_PERMISSIONS = [
# Permisos funcionales
"view_tickets",
"create_tickets",
"close_tickets",
"view_reports",
"manage_tenant_users",
"create_issues",
# Permisos de navegación - cliente
"nav:tickets",
"nav:organization",
"nav:usuarios",
]
INTERNAL_PERMISSIONS = [
# Permisos de navegación - interno
"nav:tickets",
"nav:users",
"nav:tenants",
"nav:categories",
"nav:systems",
"nav:sla",
"nav:audit",
"nav:reports",
]
DEFAULT_PERMISSIONS = {
"CLIENT_ADMIN": {p: True for p in CLIENT_PERMISSIONS},
"CLIENT_ADMIN": {
# Funcionales — todo ON
"view_tickets": True,
"create_tickets": True,
"close_tickets": True,
"view_reports": True,
"manage_tenant_users": True,
"create_issues": True,
# Navegación cliente — todo ON
"nav:tickets": True,
"nav:organization": True,
"nav:usuarios": True,
},
"CLIENT_USER": {
# Funcionales
"view_tickets": True,
"create_tickets": True,
"close_tickets": False,
"view_reports": False,
"manage_tenant_users": False,
"create_issues": False,
}
# Navegación cliente
"nav:tickets": True,
"nav:organization": False,
"nav:usuarios": False,
},
"ADMIN": {
# Navegación interna — todo ON
"nav:tickets": True,
"nav:users": True,
"nav:tenants": True,
"nav:categories": True,
"nav:systems": True,
"nav:sla": True,
"nav:audit": True,
"nav:reports": True,
},
"SUPPORT_MANAGER": {
"nav:tickets": True,
"nav:users": True,
"nav:tenants": False,
"nav:categories": True,
"nav:systems": True,
"nav:sla": True,
"nav:audit": True,
"nav:reports": True,
},
"AGENT": {
"nav:tickets": True,
"nav:users": False,
"nav:tenants": False,
"nav:categories": False,
"nav:systems": False,
"nav:sla": False,
"nav:audit": False,
"nav:reports": False,
},
"AUDITOR": {
"nav:tickets": True,
"nav:users": False,
"nav:tenants": False,
"nav:categories": False,
"nav:systems": False,
"nav:sla": False,
"nav:audit": True,
"nav:reports": True,
},
}

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { CLIENT_ROUTES } from '$lib/routes-registry';
import { auth } from '$lib/stores/auth.js';
import { navPerms } from '$lib/stores/navPermissions';
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
@@ -8,24 +10,29 @@
let isMenuOpen = false;
onMount(() => {
auth.init();
onMount(async () => {
await auth.init();
if ($auth.isAuthenticated) {
await navPerms.load();
}
});
$: visibleRoutes = CLIENT_ROUTES.filter(r => $navPerms[r.key] === true);
function toggleMenu() {
isMenuOpen = !isMenuOpen;
}
function handleLogout() {
isMenuOpen = false;
auth.logout(); // El store maneja la redirección automática
navPerms.clear();
auth.logout();
}
</script>
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo -->
{#if showLogo}
<div class="flex items-center">
<a href="/" class="flex items-center space-x-2">
@@ -40,22 +47,16 @@
</div>
{/if}
<!-- Navigation -->
{#if showNavigation && $auth.isAuthenticated}
<nav class="hidden md:flex space-x-8">
<a
href="/tickets"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Mis Tickets
</a>
<a
href="/usuarios"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Usuarios
</a>
{#each visibleRoutes as route}
<a
href={route.path}
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
{route.label}
</a>
{/each}
<a
href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
@@ -65,7 +66,6 @@
</nav>
{/if}
<!-- User menu -->
<div class="flex items-center space-x-4">
{#if $auth.isAuthenticated}
<div class="relative">
@@ -98,17 +98,13 @@
<a
href="/profile"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => (isMenuOpen = false)}
on:click={() => (isMenuOpen = false)}>Mi Perfil</a
>
Mi Perfil
</a>
<a
href="/organization"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => (isMenuOpen = false)}
on:click={() => (isMenuOpen = false)}>Mi Organización</a
>
Mi Organización
</a>
<button
on:click={handleLogout}
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
@@ -130,23 +126,17 @@
</div>
</div>
<!-- Mobile navigation -->
{#if showNavigation && $auth.isAuthenticated}
<div class="md:hidden border-t border-gray-200 py-2">
<nav class="flex space-x-4">
<a
href="/tickets"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Mis Tickets
</a>
<a
href="/usuarios"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Usuarios
</a>
{#each visibleRoutes as route}
<a
href={route.path}
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
{route.label}
</a>
{/each}
<a
href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
@@ -159,7 +149,6 @@
</div>
</header>
<!-- Backdrop for mobile menu -->
{#if isMenuOpen}
<div class="fixed inset-0 z-40 md:hidden" on:click={() => (isMenuOpen = false)} />
{/if}

View File

@@ -0,0 +1,11 @@
export interface RouteDefinition {
key: string;
label: string;
path: string;
}
export const CLIENT_ROUTES: RouteDefinition[] = [
{ key: 'nav:tickets', label: 'Mis Tickets', path: '/tickets' },
{ key: 'nav:organization', label: 'Organización', path: '/organization' },
{ key: 'nav:usuarios', label: 'Usuarios', path: '/usuarios' },
]

View File

@@ -1,7 +1,6 @@
import { writable } from 'svelte/store';
import { auth } from './auth.js';
import { get } from 'svelte/store';
import type { Writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { auth } from './auth.js';
// Types
export interface Category {

View File

@@ -0,0 +1,36 @@
import { auth } from '$lib/stores/auth';
import { get, writable } from 'svelte/store';
function createNavPermissionsStore() {
const { subscribe, set } = writable<Record<string, boolean>>({});
return {
subscribe,
load: async () => {
try {
const authState = get(auth);
const slug = authState.user?.tenant_slug || authState.user?.tenant_id || '';
const headers: Record<string, string> = {
'X-App': 'client',
'X-Tenant-Slug': slug,
};
if (authState.token) {
headers['Authorization'] = `Bearer ${authState.token}`;
}
const res = await fetch('/api/v1/permissions/my', {
credentials: 'include',
headers
});
if (res.ok) {
const perms = await res.json();
set(perms);
}
} catch (e) {
set({});
}
},
clear: () => set({})
};
}
export const navPerms = createNavPermissionsStore();

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
let profile: any = null;
let isLoading = true;
@@ -90,9 +90,8 @@
function cancelEdit() {
for (const key of Object.keys(form)) {
(form as any)[key] = (profile?.[key] !== undefined && profile?.[key] !== null)
? profile[key]
: '';
(form as any)[key] =
profile?.[key] !== undefined && profile?.[key] !== null ? profile[key] : '';
}
isEditing = false;
}
@@ -114,11 +113,7 @@
<p class="text-gray-600 mt-1">Información empresarial de tu organización</p>
</div>
{#if !isEditing && !isLoading}
<button
type="button"
class="btn-primary px-4 py-2"
on:click={() => (isEditing = true)}
>
<button type="button" class="btn-primary px-4 py-2" on:click={() => (isEditing = true)}>
Editar información
</button>
{/if}
@@ -126,12 +121,11 @@
{#if isLoading}
<div class="text-center py-16">
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
<div class="spinner w-8 h-8 mx-auto mb-4" />
<p class="text-gray-500">Cargando información de la organización...</p>
</div>
{:else}
<form on:submit|preventDefault={saveProfile} class="space-y-8">
<!-- Información general -->
<div class="card">
<div class="border-b border-gray-200 px-6 py-4">
@@ -139,7 +133,6 @@
</div>
<div class="px-6 py-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label class="form-label">Razón social</label>
{#if isEditing}
@@ -161,7 +154,14 @@
<div>
<label class="form-label">RFC</label>
{#if isEditing}
<input type="text" class="form-input" style="text-transform:uppercase" bind:value={form.rfc} maxlength="13" placeholder="XAXX010101000" />
<input
type="text"
class="form-input"
style="text-transform:uppercase"
bind:value={form.rfc}
maxlength="13"
placeholder="XAXX010101000"
/>
{:else}
<p class="text-sm font-mono text-gray-900 mt-1">{val('rfc') || '—'}</p>
{/if}
@@ -194,18 +194,25 @@
<div>
<label class="form-label">Sitio web</label>
{#if isEditing}
<input type="url" class="form-input" bind:value={form.website} placeholder="https://..." />
<input
type="url"
class="form-input"
bind:value={form.website}
placeholder="https://..."
/>
{:else if val('website')}
<p class="text-sm mt-1">
<a
href={val('website')}
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:underline">{val('website')}</a
>
</p>
{:else}
{#if val('website')}
<p class="text-sm mt-1">
<a href={val('website')} target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:underline">{val('website')}</a>
</p>
{:else}
<p class="text-sm text-gray-400 mt-1"></p>
{/if}
<p class="text-sm text-gray-400 mt-1"></p>
{/if}
</div>
</div>
</div>
</div>
@@ -217,7 +224,6 @@
</div>
<div class="px-6 py-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label class="form-label">País</label>
{#if isEditing}
@@ -248,7 +254,12 @@
<div>
<label class="form-label">Código postal</label>
{#if isEditing}
<input type="text" class="form-input" bind:value={form.postal_code} maxlength="10" />
<input
type="text"
class="form-input"
bind:value={form.postal_code}
maxlength="10"
/>
{:else}
<p class="text-sm text-gray-900 mt-1">{val('postal_code') || '—'}</p>
{/if}
@@ -262,7 +273,6 @@
<p class="text-sm text-gray-900 mt-1">{val('address') || '—'}</p>
{/if}
</div>
</div>
</div>
</div>
@@ -274,7 +284,6 @@
</div>
<div class="px-6 py-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label class="form-label">Teléfono principal</label>
{#if isEditing}
@@ -296,12 +305,16 @@
<div>
<label class="form-label">Horario de atención</label>
{#if isEditing}
<input type="text" class="form-input" bind:value={form.business_hours} placeholder="Lun-Vie 9:00-18:00" />
<input
type="text"
class="form-input"
bind:value={form.business_hours}
placeholder="Lun-Vie 9:00-18:00"
/>
{:else}
<p class="text-sm text-gray-900 mt-1">{val('business_hours') || '—'}</p>
{/if}
</div>
</div>
</div>
</div>
@@ -318,13 +331,11 @@
rows="4"
bind:value={form.notes}
placeholder="Información adicional sobre la organización..."
></textarea>
/>
{:else if val('notes')}
<p class="text-sm text-gray-900 whitespace-pre-wrap">{val('notes')}</p>
{:else}
{#if val('notes')}
<p class="text-sm text-gray-900 whitespace-pre-wrap">{val('notes')}</p>
{:else}
<p class="text-sm text-gray-400">Sin notas</p>
{/if}
<p class="text-sm text-gray-400">Sin notas</p>
{/if}
</div>
</div>
@@ -336,13 +347,9 @@
type="button"
class="btn-secondary px-5 py-2"
on:click={cancelEdit}
disabled={isSaving}
>Cancelar</button>
<button
type="submit"
class="btn-primary px-5 py-2"
disabled={isSaving}
disabled={isSaving}>Cancelar</button
>
<button type="submit" class="btn-primary px-5 py-2" disabled={isSaving}>
{isSaving ? 'Guardando...' : 'Guardar cambios'}
</button>
</div>

View File

@@ -992,19 +992,48 @@
</div>
<div class="flex items-center gap-3">
{#if $auth.user?.is_two_factor_enabled}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
<svg
class="w-3.5 h-3.5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/></svg
>
Habilitado
</span>
<button
type="button"
class="text-sm text-red-600 hover:text-red-800 font-medium"
on:click={() => { show2faDisable = !show2faDisable; disableTotpCode = ''; }}
disabled={is2faLoading}
>Deshabilitar</button>
on:click={() => {
show2faDisable = !show2faDisable;
disableTotpCode = '';
}}
disabled={is2faLoading}>Deshabilitar</button
>
{:else}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600"
>
<svg
class="w-3.5 h-3.5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
Deshabilitado
</span>
<button
@@ -1021,11 +1050,21 @@
{#if show2faSetup && qrUri}
<div class="p-5 border-t border-gray-200 space-y-4">
<p class="text-sm font-medium text-gray-700">1. Escanea este código QR en Google Authenticator, Authy o cualquier app TOTP:</p>
<p class="text-sm font-medium text-gray-700">
1. Escanea este código QR en Google Authenticator, Authy o cualquier app TOTP:
</p>
<div class="flex justify-center bg-white p-4 border border-gray-200 rounded">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data={encodeURIComponent(qrUri)}" alt="Código QR 2FA" class="w-44 h-44" />
<img
src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data={encodeURIComponent(
qrUri
)}"
alt="Código QR 2FA"
class="w-44 h-44"
/>
</div>
<p class="text-sm font-medium text-gray-700 mt-3">2. Ingresa el código de 6 dígitos para confirmar:</p>
<p class="text-sm font-medium text-gray-700 mt-3">
2. Ingresa el código de 6 dígitos para confirmar:
</p>
<div class="flex gap-3">
<input
type="text"
@@ -1045,32 +1084,45 @@
<button
type="button"
class="text-gray-500 hover:text-gray-700 text-sm font-medium"
on:click={() => { show2faSetup = false; }}
>Cancelar</button>
on:click={() => {
show2faSetup = false;
}}>Cancelar</button
>
</div>
</div>
{/if}
{#if showBackupCodes && backupCodes.length > 0}
<div class="p-5 border-t border-green-200 bg-green-50">
<h4 class="font-medium text-green-900 mb-2">✅ 2FA activado — Guarda tus códigos de respaldo</h4>
<p class="text-sm text-green-700 mb-3">Estos códigos son de un solo uso. Guárdalos en un lugar seguro.</p>
<h4 class="font-medium text-green-900 mb-2">
✅ 2FA activado — Guarda tus códigos de respaldo
</h4>
<p class="text-sm text-green-700 mb-3">
Estos códigos son de un solo uso. Guárdalos en un lugar seguro.
</p>
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
{#each backupCodes as code}
<span class="bg-white border border-green-200 px-3 py-1.5 rounded text-center">{code}</span>
<span class="bg-white border border-green-200 px-3 py-1.5 rounded text-center"
>{code}</span
>
{/each}
</div>
<button
type="button"
class="mt-4 text-sm text-green-700 underline"
on:click={() => { showBackupCodes = false; backupCodes = []; }}
>He guardado mis códigos</button>
on:click={() => {
showBackupCodes = false;
backupCodes = [];
}}>He guardado mis códigos</button
>
</div>
{/if}
{#if show2faDisable}
<div class="p-5 border-t border-red-200 bg-red-50">
<p class="text-sm font-medium text-red-800 mb-3">Ingresa el código de tu app autenticadora para deshabilitar 2FA:</p>
<p class="text-sm font-medium text-red-800 mb-3">
Ingresa el código de tu app autenticadora para deshabilitar 2FA:
</p>
<div class="flex gap-3">
<input
type="text"
@@ -1090,8 +1142,10 @@
<button
type="button"
class="text-gray-500 hover:text-gray-700 text-sm"
on:click={() => { show2faDisable = false; }}
>Cancelar</button>
on:click={() => {
show2faDisable = false;
}}>Cancelar</button
>
</div>
</div>
{/if}

View File

@@ -1,30 +1,29 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { goto } from '$app/navigation';
import TicketCard from '$lib/components/TicketCard.svelte';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { onMount } from 'svelte';
let searchQuery = '';
let statusFilter = '';
let priorityFilter = '';
let filteredTickets: any[] = [];
onMount(() => {
// Redirect if not authenticated
onMount(async () => {
await auth.init();
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
// Load tickets
tickets.loadTickets();
});
// Filter tickets based on search and filters
$: {
filteredTickets = $tickets.tickets.filter(ticket => {
const matchesSearch = !searchQuery ||
const matchesSearch =
!searchQuery ||
ticket.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
ticket.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
ticket.id.toLowerCase().includes(searchQuery.toLowerCase());
@@ -58,16 +57,16 @@
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">Mis Tickets</h1>
<p class="text-gray-600 mt-2">
Gestiona y da seguimiento a todos tus tickets de soporte
</p>
<p class="text-gray-600 mt-2">Gestiona y da seguimiento a todos tus tickets de soporte</p>
</div>
<a
href="/tickets/new"
class="btn-primary px-4 py-2 inline-flex items-center space-x-2"
>
<a href="/tickets/new" class="btn-primary px-4 py-2 inline-flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<span>Crear Ticket</span>
</a>
@@ -79,8 +78,18 @@
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
<svg
class="w-4 h-4 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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"
/>
</svg>
</div>
<div class="ml-3">
@@ -95,8 +104,18 @@
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
class="w-4 h-4 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="ml-3">
@@ -113,8 +132,18 @@
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
class="w-4 h-4 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="ml-3">
@@ -131,8 +160,18 @@
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<svg
class="w-4 h-4 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div class="ml-3">
@@ -163,11 +202,7 @@
<div>
<label for="status-filter" class="form-label">Estado</label>
<select
id="status-filter"
class="form-input"
bind:value={statusFilter}
>
<select id="status-filter" class="form-input" bind:value={statusFilter}>
<option value="">Todos los estados</option>
<option value="NEW">Nuevo</option>
<option value="IN_PROGRESS">En Progreso</option>
@@ -180,11 +215,7 @@
<div>
<label for="priority-filter" class="form-label">Prioridad</label>
<select
id="priority-filter"
class="form-input"
bind:value={priorityFilter}
>
<select id="priority-filter" class="form-input" bind:value={priorityFilter}>
<option value="">Todas las prioridades</option>
<option value="LOW">Baja</option>
<option value="MEDIUM">Media</option>
@@ -194,10 +225,7 @@
</div>
<div class="flex items-end">
<button
on:click={clearFilters}
class="btn-secondary px-4 py-2 w-full"
>
<button on:click={clearFilters} class="btn-secondary px-4 py-2 w-full">
Limpiar Filtros
</button>
</div>
@@ -209,22 +237,24 @@
<div class="space-y-6">
{#if $tickets.isLoading}
<div class="text-center py-12">
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
<div class="spinner w-8 h-8 mx-auto mb-4" />
<p class="text-gray-600">Cargando tickets...</p>
</div>
{:else if $tickets.error}
<div class="text-center py-12">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar tickets</h3>
<p class="text-gray-600 mb-4">{$tickets.error}</p>
<button
on:click={() => tickets.loadTickets()}
class="btn-primary px-4 py-2"
>
<button on:click={() => tickets.loadTickets()} class="btn-primary px-4 py-2">
Reintentar
</button>
</div>
@@ -232,7 +262,12 @@
<div class="text-center py-12">
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">
@@ -244,13 +279,9 @@
: 'Intenta ajustar los filtros de búsqueda'}
</p>
{#if $tickets.tickets.length === 0}
<a href="/tickets/new" class="btn-primary px-4 py-2">
Crear Ticket
</a>
<a href="/tickets/new" class="btn-primary px-4 py-2"> Crear Ticket </a>
{:else}
<button on:click={clearFilters} class="btn-secondary px-4 py-2">
Limpiar Filtros
</button>
<button on:click={clearFilters} class="btn-secondary px-4 py-2"> Limpiar Filtros </button>
{/if}
</div>
{:else}

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { CLIENT_ROUTES } from '$lib/routes-registry';
import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
// ---- Tipos ----
interface User {
id: string;
email: string;
@@ -17,7 +17,6 @@
can_manage_users?: boolean;
}
// ---- Estado ----
let users: User[] = [];
let isLoading = true;
let showModal = false;
@@ -27,6 +26,8 @@
let searchQuery = '';
let filterRole = '';
let filterStatus = '';
let navPermissions: Record<string, boolean> = {};
let isLoadingPerms = false;
let form = {
first_name: '',
@@ -41,22 +42,47 @@
let isSaving = false;
let isDeleting = false;
// ---- Permisos ----
$: currentRole = $auth.user?.role ?? '';
$: isClientAdmin = currentRole === 'CLIENT_ADMIN';
$: canManageUsers = isClientAdmin;
// ---- Utilidades ----
function apiHeaders(): Record<string, string> {
const h: Record<string, string> = {
'X-App': 'client',
'X-Tenant-Slug': $auth.user?.tenant_slug || $auth.user?.tenant_id || '',
'X-Tenant-Slug': $auth.user?.tenant_slug || $auth.user?.tenant_id || ''
};
if ($auth.token) h['Authorization'] = `Bearer ${$auth.token}`;
return h;
}
// ---- Carga de usuarios ----
function initNavPermissions() {
navPermissions = {};
CLIENT_ROUTES.forEach(r => {
navPermissions[r.key] = false;
});
}
async function loadUserNavPermissions(userId: string) {
isLoadingPerms = true;
try {
const res = await fetch(`/api/v1/permissions/user/${userId}`, {
credentials: 'include',
headers: apiHeaders()
});
if (!res.ok) throw new Error('Error cargando permisos');
const perms = await res.json();
const updated: Record<string, boolean> = {};
CLIENT_ROUTES.forEach(r => {
updated[r.key] = perms[r.key] ?? false;
});
navPermissions = updated;
} catch (e) {
initNavPermissions();
} finally {
isLoadingPerms = false;
}
}
async function loadUsers() {
isLoading = true;
try {
@@ -74,31 +100,42 @@
}
onMount(async () => {
if (!$auth.isAuthenticated) { goto('/login'); return; }
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
await loadUsers();
});
// ---- Filtros reactivos ----
$: filteredUsers = users.filter(u => {
const q = searchQuery.toLowerCase();
const matchSearch = !q ||
const matchSearch =
!q ||
u.first_name?.toLowerCase().includes(q) ||
u.last_name?.toLowerCase().includes(q) ||
u.email?.toLowerCase().includes(q);
const matchRole = !filterRole || u.role === filterRole;
const matchStatus = filterStatus === '' ? true :
filterStatus === 'active' ? u.is_active : !u.is_active;
const matchStatus =
filterStatus === '' ? true : filterStatus === 'active' ? u.is_active : !u.is_active;
return matchSearch && matchRole && matchStatus;
});
// ---- Modal ----
function openCreate() {
modalMode = 'create';
form = { first_name: '', last_name: '', email: '', password: '', role: 'CLIENT_USER', is_active: true, can_manage_users: false };
form = {
first_name: '',
last_name: '',
email: '',
password: '',
role: 'CLIENT_USER',
is_active: true,
can_manage_users: false
};
initNavPermissions();
showModal = true;
}
function openEdit(user: User) {
async function openEdit(user: User) {
modalMode = 'edit';
selectedUser = user;
form = {
@@ -110,6 +147,8 @@
is_active: user.is_active,
can_manage_users: user.can_manage_users || false
};
initNavPermissions();
await loadUserNavPermissions(user.id);
showModal = true;
}
@@ -118,16 +157,13 @@
selectedUser = null;
}
// ---- CRUD ----
async function saveUser() {
isSaving = true;
try {
const payload: any = { ...form };
if (modalMode === 'edit' && !payload.password) delete payload.password;
const url = modalMode === 'create'
? '/api/v1/users/'
: `/api/v1/users/${selectedUser?.id}`;
const url = modalMode === 'create' ? '/api/v1/users/' : `/api/v1/users/${selectedUser!.id}`;
const method = modalMode === 'create' ? 'POST' : 'PUT';
const res = await fetch(url, {
@@ -138,7 +174,26 @@
});
if (!res.ok) throw new Error((await res.json()).detail || 'Error al guardar');
toast.success(modalMode === 'create' ? 'Usuario creado correctamente' : 'Usuario actualizado');
const savedUser = await res.json();
const userId = modalMode === 'create' ? savedUser.id : selectedUser!.id;
// Guardar permisos de navegación
if (userId) {
try {
await fetch(`/api/v1/permissions/user/${userId}`, {
method: 'PUT',
credentials: 'include',
headers: { ...apiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(navPermissions)
});
} catch (e) {
toast.error('Usuario guardado pero error al guardar permisos de navegación');
}
}
toast.success(
modalMode === 'create' ? 'Usuario creado correctamente' : 'Usuario actualizado'
);
closeModal();
await loadUsers();
} catch (e: any) {
@@ -193,7 +248,6 @@
showDeleteConfirm = true;
}
// ---- Helpers visuales ----
function roleLabel(role: string): string {
return role === 'CLIENT_ADMIN' ? 'Administrador' : 'Usuario';
}
@@ -212,9 +266,15 @@
function avatarColor(email: string): string {
const colors = [
'bg-rose-400', 'bg-orange-400', 'bg-amber-400',
'bg-emerald-400', 'bg-teal-400', 'bg-cyan-400',
'bg-blue-400', 'bg-violet-400', 'bg-pink-400'
'bg-rose-400',
'bg-orange-400',
'bg-amber-400',
'bg-emerald-400',
'bg-teal-400',
'bg-cyan-400',
'bg-blue-400',
'bg-violet-400',
'bg-pink-400'
];
let hash = 0;
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
@@ -227,13 +287,11 @@
</svelte:head>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Encabezado -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<h1 class="text-2xl font-bold text-gray-900">Usuarios</h1>
<p class="text-sm text-gray-500 mt-1">
Miembros de tu organización· {users.length} en total
Miembros de tu organización · {users.length} en total
</p>
</div>
{#if isClientAdmin}
@@ -243,79 +301,111 @@
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Nuevo usuario
</button>
{/if}
</div>
<!-- Filtros -->
<div class="flex flex-col sm:flex-row gap-3 mb-6">
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
placeholder="Buscar por nombre o email..."
bind:value={searchQuery}
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
/>
</div>
<select bind:value={filterRole} class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Roles </option>
<select
bind:value={filterRole}
class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Roles</option>
<option value="CLIENT_ADMIN">Administrador</option>
<option value="CLIENT_USER">Usuario</option>
</select>
<select bind:value={filterStatus} class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Estado </option>
<select
bind:value={filterStatus}
class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Estado</option>
<option value="active">Activos</option>
<option value="inactive">Inactivos</option>
</select>
</div>
<!-- Tabla -->
{#if isLoading}
<div class="flex items-center justify-center py-24">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
{:else if filteredUsers.length === 0}
<div class="text-center py-20 bg-white rounded-xl border border-gray-100">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<p class="text-gray-500 font-medium">No se encontraron usuarios</p>
<p class="text-sm text-gray-400 mt-1">Intenta ajustar los filtros de búsqueda</p>
</div>
{:else}
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 bg-gray-50">
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Usuario</th>
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Rol</th>
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:table-cell">Miembro desde</th>
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Estado</th>
<th
class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider"
>Usuario</th
>
<th
class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider"
>Rol</th
>
<th
class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:table-cell"
>Miembro desde</th
>
<th
class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider"
>Estado</th
>
{#if canManageUsers}
<th class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">Acciones</th>
<th
class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right"
>Acciones</th
>
{/if}
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{#each filteredUsers as user (user.id)}
<tr class="hover:bg-gray-50/50 transition-colors group">
<!-- Avatar + info -->
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
<div
class="w-9 h-9 rounded-full {avatarColor(
user.email
)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
>
{initials(user)}
</div>
<div class="min-w-0">
<p class="font-medium text-gray-900 truncate">
{user.first_name || ''} {user.last_name || ''}
{user.first_name || ''}
{user.last_name || ''}
{#if user.id === $auth.user?.id}
<span class="ml-1 text-xs text-gray-400">(tú)</span>
{/if}
@@ -324,45 +414,42 @@
</div>
</div>
</td>
<!-- Rol -->
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {roleBadgeClass(user.role)}">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {roleBadgeClass(
user.role
)}"
>
{roleLabel(user.role)}
</span>
{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 ring-1 ring-amber-200">
Gestión
</span>
{/if}
</td>
<!-- Fecha -->
<td class="px-6 py-4 text-gray-500 hidden sm:table-cell">
{user.created_at ? new Date(user.created_at).toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' }) : '—'}
{user.created_at
? new Date(user.created_at).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
: '—'}
</td>
<!-- Estado -->
<td class="px-6 py-4">
{#if user.is_active}
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-700">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
Activo
<span
class="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-700"
>
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500" />Activo
</span>
{:else}
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-gray-400">
<span class="w-1.5 h-1.5 rounded-full bg-gray-300"></span>
Inactivo
<span class="w-1.5 h-1.5 rounded-full bg-gray-300" />Inactivo
</span>
{/if}
</td>
<!-- Acciones -->
{#if canManageUsers}
<td class="px-6 py-4">
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<!-- Editar -->
<div
class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
type="button"
on:click={() => openEdit(user)}
@@ -370,11 +457,14 @@
title="Editar usuario"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<!-- Activar/Desactivar -->
<button
type="button"
on:click={() => toggleActive(user)}
@@ -386,16 +476,24 @@
>
{#if user.is_active}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/if}
</button>
<!-- Eliminar (solo CLIENT_ADMIN) -->
{#if isClientAdmin && user.id !== $auth.user?.id}
<button
type="button"
@@ -404,11 +502,15 @@
title="Eliminar usuario"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
</td>
{/if}
@@ -423,59 +525,86 @@
<!-- Modal crear/editar -->
{#if showModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={closeModal}></div>
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 z-10">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={closeModal} />
<div
class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 z-10 max-h-[90vh] overflow-y-auto"
>
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-gray-900">
{modalMode === 'create' ? 'Nuevo usuario' : 'Editar usuario'}
</h2>
<button type="button" on:click={closeModal} class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
<button
type="button"
on:click={closeModal}
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<form on:submit|preventDefault={saveUser} class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre</label>
<input type="text" bind:value={form.first_name} required
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
<input
type="text"
bind:value={form.first_name}
required
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Apellido</label>
<input type="text" bind:value={form.last_name}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
<input
type="text"
bind:value={form.last_name}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" bind:value={form.email} required
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
<input
type="email"
bind:value={form.email}
required
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{modalMode === 'create' ? 'Contraseña' : 'Nueva contraseña (dejar vacío para no cambiar)'}
{modalMode === 'create'
? 'Contraseña'
: 'Nueva contraseña (dejar vacío para no cambiar)'}
</label>
<input type="password" bind:value={form.password} required={modalMode === 'create'}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
<input
type="password"
bind:value={form.password}
required={modalMode === 'create'}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Rol</label>
<select bind:value={form.role}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
<select
bind:value={form.role}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value="CLIENT_USER">Usuario</option>
<option value="CLIENT_ADMIN">Administrador</option>
</select>
</div>
<!-- Permiso de gestión (solo si el rol es CLIENT_USER) -->
{#if form.role === 'CLIENT_USER'}
<div class="flex items-start gap-3 p-3 bg-amber-50 rounded-lg border border-amber-100">
<input
@@ -486,7 +615,9 @@
/>
<label for="can_manage_users" class="text-sm text-gray-700 cursor-pointer">
<span class="font-medium">Permitir gestión de usuarios</span>
<p class="text-xs text-gray-500 mt-0.5">Podrá crear y editar usuarios, pero no eliminarlos</p>
<p class="text-xs text-gray-500 mt-0.5">
Podrá crear y editar usuarios, pero no eliminarlos
</p>
</label>
</div>
{/if}
@@ -499,47 +630,106 @@
bind:checked={form.is_active}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label for="is_active" class="text-sm font-medium text-gray-700 cursor-pointer">Usuario activo</label>
<label for="is_active" class="text-sm font-medium text-gray-700 cursor-pointer"
>Usuario activo</label
>
</div>
{/if}
<div class="flex justify-end gap-3 pt-2">
<button type="button" on:click={closeModal}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
Cancelar
</button>
<button type="submit" disabled={isSaving}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60 transition-colors">
{isSaving ? 'Guardando...' : modalMode === 'create' ? 'Crear usuario' : 'Guardar cambios'}
</button>
<!-- Sección de vistas -->
<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...</span>
{/if}
</div>
<div class="space-y-2">
{#each CLIENT_ROUTES as route}
<label
class="flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer hover:bg-gray-50 transition-colors
{navPermissions[route.key]
? 'border-blue-200 bg-blue-50'
: 'border-gray-100 bg-gray-50'}"
>
<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>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
on:click={closeModal}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={isSaving}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60 transition-colors"
>
{isSaving
? 'Guardando...'
: modalMode === 'create'
? 'Crear usuario'
: 'Guardar cambios'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Confirmacióclsn eliminar -->
<!-- Confirmar eliminar -->
{#if showDeleteConfirm && selectedUser}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => showDeleteConfirm = false}></div>
<div
class="absolute inset-0 bg-black/40 backdrop-blur-sm"
on:click={() => (showDeleteConfirm = false)}
/>
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6 z-10">
<div class="w-12 h-12 rounded-full bg-rose-100 flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-rose-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h3 class="text-center font-semibold text-gray-900 mb-1">Eliminar usuario</h3>
<p class="text-center text-sm text-gray-500 mb-6">
¿Seguro que quieres eliminar a <strong>{selectedUser.first_name} {selectedUser.last_name}</strong>? Esta acción no se puede deshacer.
¿Seguro que quieres eliminar a <strong
>{selectedUser.first_name} {selectedUser.last_name}</strong
>? Esta acción no se puede deshacer.
</p>
<div class="flex gap-3">
<button type="button" on:click={() => showDeleteConfirm = false}
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<button
type="button"
on:click={() => (showDeleteConfirm = false)}
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button type="button" on:click={deleteUser} disabled={isDeleting}
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-rose-600 rounded-lg hover:bg-rose-700 disabled:opacity-60 transition-colors">
<button
type="button"
on:click={deleteUser}
disabled={isDeleting}
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-rose-600 rounded-lg hover:bg-rose-700 disabled:opacity-60 transition-colors"
>
{isDeleting ? 'Eliminando...' : 'Eliminar'}
</button>
</div>

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,15 +276,47 @@
</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>