feature/optimizacion-de-permisos

This commit is contained in:
2026-05-29 09:48:43 -06:00
parent 84d5f092d8
commit e2445d454b
25 changed files with 1003 additions and 170 deletions

View File

@@ -164,9 +164,45 @@ const createAuthStore = () => {
setLoading: (loading: boolean) =>
update((s) => ({ ...s, isLoading: loading })),
setUser: (user: User | null) => {
if (user === null) {
persistUserInSession(null);
update((s) => ({ ...s, user: null }));
return;
}
update((s) => {
const prev = s.user;
const permissions = preserveNonEmptyArray(user.permissions, prev?.permissions);
const roles = preserveNonEmptyArray(user.roles, prev?.roles);
const allowedSystems = preserveNonEmptyArray(user.allowedSystems, prev?.allowedSystems);
if (
prev &&
Array.isArray(user.permissions) &&
user.permissions.length === 0 &&
(prev.permissions?.length ?? 0) > 0
) {
console.debug(
'[auth] setUser: previene downgrade de permissions',
prev.permissions.length,
'→ 0'
);
}
const merged: User = { ...user, permissions, roles, allowedSystems };
persistUserInSession(merged);
return { ...s, user: merged };
});
},
/** Asignación directa sin guard. Usar solo cuando el backend confirma el estado (p. ej. syncCompanyPermissions). */
setUserUnsafe: (user: User | null) => {
persistUserInSession(user);
update((s) => ({ ...s, user }));
},
/** Limpia solo el usuario del store (logout parcial). Para logout completo usar reset(). */
clearUser: () => {
persistUserInSession(null);
update((s) => ({ ...s, user: null }));
},
setToken: (token: string | null) => update((s) => ({ ...s, token })),
/** ⚠️ Los tokens ya NO se guardan en localStorage; solo en memoria. */
setTokens: (accessToken: string, _refreshToken?: string) => {
@@ -201,18 +237,144 @@ export const currentUser = derived(authStore, ($a) => $a.user);
*/
export const permissionsHydrated = writable<boolean>(false);
/** true mientras `refreshPermissions` revalida permisos en segundo plano (sidebar). */
export const permissionsRefreshing = writable<boolean>(false);
export function markPermissionsHydrated(): void {
permissionsHydrated.set(true);
}
/** No sobrescribir permisos RBAC con [] de /v1/auth/me (Hub no es fuente de verdad).
* Defensa en profundidad: el guard de authStore.setUser ya cubre esto. */
function mergePermissionsFromHub(incoming: unknown, previous: string[] | undefined): string[] {
if (Array.isArray(incoming) && incoming.length > 0) return incoming;
if (previous && previous.length > 0) return previous;
return Array.isArray(incoming) ? (incoming as string[]) : [];
}
/** Preserva roles previos (p. ej. admin del Hub) al fusionar con /v1/auth/me.
* Defensa en profundidad: el guard de authStore.setUser ya cubre esto. */
function mergeRolesFromHub(incoming: unknown, previous: string[] | undefined): string[] {
const prev = previous ?? [];
if (Array.isArray(incoming) && incoming.length > 0) {
return Array.from(new Set([...prev, ...incoming]));
}
return prev;
}
/**
* Merge defensivo: preserva el array previo si el incoming es undefined,
* null o [] (cuando previo no era vacío). Un incoming con items siempre
* se respeta — la revocación parcial sí es válida.
*
* Para una revocación TOTAL legítima usa `authStore.setUserUnsafe()`.
*/
export function preserveNonEmptyArray<T>(
incoming: T[] | null | undefined,
previous: T[] | undefined
): T[] {
const prev = previous ?? [];
if (incoming == null) return prev;
if (Array.isArray(incoming) && incoming.length === 0 && prev.length > 0) {
return prev;
}
return Array.isArray(incoming) ? incoming : prev;
}
// ─────────────────────────────────────────────────────────
// Cache local de permisos del sidebar (localStorage, TTL 5 min)
// ─────────────────────────────────────────────────────────
const SIDEBAR_PERMS_CACHE_PREFIX = 'a76:perms:v1:';
const SIDEBAR_PERMS_CACHE_TTL_MS = 5 * 60 * 1000;
interface SidebarPermsCacheEntry {
permissions: string[];
roles: string[];
allowedSystems: string[];
tenantId?: number;
cachedAt: number;
}
function buildSidebarPermsCacheKey(userId: string, companyId: number): string {
return `${SIDEBAR_PERMS_CACHE_PREFIX}u${userId}:c${companyId}`;
}
function saveSidebarPermsCache(
userId: string,
companyId: number,
data: Omit<SidebarPermsCacheEntry, 'cachedAt'>
): void {
if (!browser) return;
try {
const entry: SidebarPermsCacheEntry = { ...data, cachedAt: Date.now() };
localStorage.setItem(buildSidebarPermsCacheKey(userId, companyId), JSON.stringify(entry));
} catch {
// localStorage puede estar restringido — no romper la app
}
}
export function loadSidebarPermsCache(
userId: string,
companyId: number
): SidebarPermsCacheEntry | null {
if (!browser) return null;
try {
const raw = localStorage.getItem(buildSidebarPermsCacheKey(userId, companyId));
if (!raw) return null;
const entry = JSON.parse(raw) as SidebarPermsCacheEntry;
if (!entry || typeof entry !== 'object') return null;
if (!Array.isArray(entry.permissions) || !Array.isArray(entry.roles)) return null;
if (Date.now() - (entry.cachedAt ?? 0) > SIDEBAR_PERMS_CACHE_TTL_MS) return null;
return entry;
} catch {
return null;
}
}
function clearSidebarPermsCache(userId?: string): void {
if (!browser) return;
try {
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key || !key.startsWith(SIDEBAR_PERMS_CACHE_PREFIX)) continue;
if (userId === undefined || key.includes(`u${userId}:`)) {
keys.push(key);
}
}
for (const key of keys) {
localStorage.removeItem(key);
}
} catch {
// silencioso
}
}
/**
* Códigos de rol que dan bypass total a checks de permiso:
* - `super_admin`: rol local de la compañía (fuente de verdad post-desacoplamiento).
* - `admin`: rol del realm Keycloak (preservado por compat con `/v1/auth/me` del Hub).
* - `hub_admin`: super-admin atestado por el Hub.
*/
const ADMIN_ROLE_CODES: ReadonlySet<string> = new Set(['super_admin', 'admin', 'hub_admin']);
export function userIsAdmin(user: User | null): boolean {
if (!user) return false;
return user.roles.some((role) => ADMIN_ROLE_CODES.has(role));
}
/**
* Verifica si el usuario tiene un permiso específico.
* `user.permissions` debe incluir códigos de la app (p. ej. `user.view`); suelen
* cargarse vía {@link syncCompanyPermissions}, no solo desde /auth/me del Hub.
* `user.permissions` debe incluir códigos de la app (p. ej. `user.view`); se
* cargan vía {@link syncCompanyPermissions} desde `/v1/core/permissions/me`.
*
* Bypass para super-admins: los códigos en {@link ADMIN_ROLE_CODES} dan acceso
* total sin requerir un permiso granular específico.
*/
export function userHasPermission(user: User | null, permission: string): boolean {
if (!user) return false;
return user.roles.includes('admin') || user.permissions.includes(permission);
return userIsAdmin(user) || user.permissions.includes(permission);
}
// ─────────────────────────────────────────────────────────
@@ -296,10 +458,11 @@ const updateAuthState = async () => {
const token = keycloakInstance.token ?? null;
const parsed = keycloakInstance.tokenParsed as any;
const roles: string[] = parsed?.realm_access?.roles ?? [];
const tenantId: number | undefined = parsed?.tenant_id
? parseInt(parsed.tenant_id)
: undefined;
// Roles y tenant_id se obtienen desde el backend (/v1/auth/me y /permissions/me),
// no desde claims del JWT de Keycloak.
const previousUser = get(authStore).user;
const tenantId: number | undefined = previousUser?.tenantId;
const roles: string[] = previousUser?.roles ?? [];
const tenantChanged = previousTenantId !== undefined && previousTenantId !== tenantId;
@@ -311,7 +474,6 @@ const updateAuthState = async () => {
currentPerms = currentState.user?.permissions || [];
} catch { }
const previousUser = get(authStore).user;
const firstName = pickText(profile.firstName, parsed?.given_name, previousUser?.firstName);
const lastName = pickText(profile.lastName, parsed?.family_name, previousUser?.lastName);
const fullNameFromParts = pickText(
@@ -346,7 +508,8 @@ const updateAuthState = async () => {
legacyAvatarUrl: pickAvatar(previousUser?.legacyAvatarUrl),
tenantId,
roles,
permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms,
// Los permisos efectivos vienen del backend (/permissions/me); el JWT no decide autorización.
permissions: currentPerms,
allowedSystems: previousUser?.allowedSystems ?? [],
profileSyncedAt: previousUser?.profileSyncedAt
};
@@ -481,11 +644,38 @@ export const login = async (credentials: {
*/
export async function syncCompanyPermissions(companyId: number): Promise<void> {
if (!browser || !Number.isFinite(companyId)) return;
// Pre-popular desde localStorage para que el sidebar renderice sin parpadeo
const preUserId = get(authStore).user?.id;
if (preUserId) {
const cached = loadSidebarPermsCache(preUserId, companyId);
if (cached) {
const s = get(authStore);
if (s.user) {
const mergedRoles = Array.from(new Set([...(s.user.roles ?? []), ...cached.roles]));
authStore.setUser({
...s.user,
permissions: cached.permissions,
roles: mergedRoles,
allowedSystems:
cached.allowedSystems.length > 0
? (cached.allowedSystems as import('./stores/system.svelte').SystemType[])
: s.user.allowedSystems,
tenantId: cached.tenantId ?? s.user.tenantId
});
permissionsHydrated.set(true);
}
}
}
try {
const { api } = await import('./api');
const res = await api.get<{ permissions: string[]; allowed_systems?: string[] }>(
`/v1/core/permissions/me?company_id=${companyId}`
);
const res = await api.get<{
permissions: string[];
roles?: string[];
allowed_systems?: string[];
tenant_id?: number | null;
}>(`/v1/core/permissions/me?company_id=${companyId}`);
if (res.error || res.data === undefined) return;
const perms = res.data.permissions;
if (!Array.isArray(perms)) return;
@@ -493,11 +683,32 @@ export async function syncCompanyPermissions(companyId: number): Promise<void> {
if (!state.user) return;
const { systemStore } = await import('./stores/system.svelte');
const allowedSystems = (res.data.allowed_systems ?? []) as import('./stores/system.svelte').SystemType[];
const rolesFromBackend = Array.isArray(res.data.roles) ? res.data.roles : null;
const tenantFromBackend =
typeof res.data.tenant_id === 'number' && Number.isFinite(res.data.tenant_id)
? res.data.tenant_id
: null;
authStore.setUser({
// Merge no destructivo de roles: preservamos los roles atestados por el
// Hub (p. ej. `admin` del realm Keycloak en /v1/auth/me) y agregamos los
// roles locales devueltos por el backend (`super_admin`, etc.). Si los
// roles locales sobrescribieran a los del Hub, el bypass de admin se
// rompería entre la primera hidratación y la sincronización por compañía.
const previousRoles = state.user.roles ?? [];
const mergedRoles =
rolesFromBackend === null
? previousRoles
: Array.from(new Set([...previousRoles, ...rolesFromBackend]));
// El backend es fuente de verdad para permisos de la compañía activa.
// Si retornó [] es porque el usuario realmente no tiene permisos aquí;
// el guard de setUser preservaría los viejos (incorrectos). Por eso unsafe.
authStore.setUserUnsafe({
...state.user,
permissions: perms,
// Preservar allowedSystems del SSR si el API no los retorna (JWT sin claim, seed pendiente)
roles: mergedRoles,
tenantId: tenantFromBackend ?? state.user.tenantId,
// Preservar allowedSystems del SSR si el API no los retorna (seed pendiente)
allowedSystems: allowedSystems.length > 0 ? allowedSystems : (state.user.allowedSystems ?? [])
});
@@ -509,6 +720,19 @@ export async function syncCompanyPermissions(companyId: number): Promise<void> {
: null;
systemStore.initialize(allowedSystems, cookieSystem);
}
// Persistir en localStorage para que el sidebar pre-popule sin parpadeo en la próxima sesión
const freshUserId = get(authStore).user?.id;
if (freshUserId) {
saveSidebarPermsCache(freshUserId, companyId, {
permissions: perms,
roles: mergedRoles,
allowedSystems: allowedSystems.length > 0
? (allowedSystems as string[])
: ((get(authStore).user?.allowedSystems ?? []) as string[]),
tenantId: tenantFromBackend ?? get(authStore).user?.tenantId
});
}
} catch (e) {
console.warn('[auth] syncCompanyPermissions:', e);
} finally {
@@ -518,10 +742,13 @@ export async function syncCompanyPermissions(companyId: number): Promise<void> {
}
}
export const refreshPermissions = async () => {
export const refreshPermissions = async (): Promise<boolean> => {
const token = getToken();
if (token) {
await loadUserInfo(token);
if (!token) return false;
permissionsRefreshing.set(true);
try {
// RBAC de compañía primero: evita que /auth/me vacíe permisos antes del sync real.
try {
const { companyStore } = await import('./stores/company.svelte');
const cid = companyStore.activeCompany?.id;
@@ -529,9 +756,11 @@ export const refreshPermissions = async () => {
} catch {
// ignore
}
await loadUserInfo(token);
return true;
} finally {
permissionsRefreshing.set(false);
}
return false;
};
const loadUserInfo = async (token: string) => {
@@ -605,8 +834,8 @@ const loadUserInfo = async (token: string) => {
workspaceAvatarUrl,
legacyAvatarUrl,
tenantId: d.tenant_id ?? previousUser?.tenantId,
roles: d.roles ?? previousUser?.roles ?? [],
permissions: d.permissions ?? previousUser?.permissions ?? [],
roles: mergeRolesFromHub(d.roles, previousUser?.roles),
permissions: mergePermissionsFromHub(d.permissions, previousUser?.permissions),
allowedSystems: previousUser?.allowedSystems ?? [],
profileSyncedAt: Date.now()
});
@@ -715,6 +944,19 @@ export const logout = async () => {
companyStore.clear();
} catch { }
// Limpiar cache de permisos del sidebar en localStorage
try {
const userId = get(authStore).user?.id;
clearSidebarPermsCache(userId);
} catch { }
// Limpiar snapshot visual del sidebar almacenado en sessionStorage
try {
sessionStorage.removeItem('a76:sidebar:nav-main:v1');
} catch {
// ignore
}
// Limpiar estado en memoria
authStore.reset();
persistUserInSession(null);

View File

@@ -4,6 +4,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
// 1. Agregamos FileDown a los imports
import { Ellipsis, Eye, Pencil, Trash2 } from 'lucide-svelte';
import { goto } from '$app/navigation';
import DetailsDialog from './details-dialog.svelte';
import DeleteDialog from './delete-dialog.svelte';
@@ -19,7 +20,7 @@
let showDelete = $state(false);
function handleEdit() {
window.location.href = `/dashboard/invoices/edit/${invoice.id}`;
void goto(`/dashboard/invoices/edit/${invoice.id}`);
}
</script>

View File

@@ -1,5 +1,6 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
@@ -76,7 +77,7 @@
function handleRowDoubleClick(row: any) {
const invoice = row.original;
if (invoice?.id) {
window.location.href = `/dashboard/invoices/edit/${invoice.id}`;
void goto(`/dashboard/invoices/edit/${invoice.id}`);
}
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { goto } from '$app/navigation';
import { pedimentosApi, type Pedimento } from "$lib/api/dashboard/a76/pedimentos";
import { companyStore } from "$lib/stores/company.svelte";
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
@@ -55,8 +56,7 @@
}
function handleEdit() {
// Navegar a la página de edición
window.location.href = `/dashboard/pedimentos/edit/${item.id}`;
void goto(`/dashboard/pedimentos/edit/${item.id}`);
}
</script>

View File

@@ -1,5 +1,6 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import {
type ColumnDef,
getCoreRowModel,
@@ -67,7 +68,7 @@
function handleRowDoubleClick(row: any) {
const pedimento = row.original;
if (pedimento?.id) {
window.location.href = `/dashboard/pedimentos/edit/${pedimento.id}`;
void goto(`/dashboard/pedimentos/edit/${pedimento.id}`);
}
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { currentUser } from '$lib/auth';
import { currentUser, userIsAdmin } from '$lib/auth';
import * as Sheet from '$lib/components/ui/sheet';
import { Button } from '$lib/components/ui/button';
import {
@@ -36,7 +36,7 @@
let hasError = $state(false);
let isLoaded = $state(false);
const isAdmin = $derived($currentUser?.roles?.includes('admin') || false);
const isAdmin = $derived(userIsAdmin($currentUser));
const currentPath = $derived(page.url.pathname + page.url.search);
const synonyms: Record<string, string[]> = {

View File

@@ -4,10 +4,20 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import { authStore, userHasPermission } from '$lib/auth';
import { authStore, permissionsRefreshing, permissionsHydrated, userHasPermission } from '$lib/auth';
import { systemStore } from '$lib/stores/system.svelte';
import { page } from '$app/state';
function resolveVisibleNavItems<T>(
filtered: T[],
lastNonEmpty: T[],
hasAuthenticatedUser: boolean
): T[] {
if (filtered.length > 0) return filtered;
if (hasAuthenticatedUser && lastNonEmpty.length > 0) return lastNonEmpty;
return filtered;
}
let {
items
}: {
@@ -63,6 +73,48 @@
})
);
type NavMainItem = (typeof items)[number];
const NAV_SNAPSHOT_KEY = 'a76:sidebar:nav-main:v1';
function loadNavSnapshot(): NavMainItem[] {
if (typeof window === 'undefined') return [];
try {
const raw = sessionStorage.getItem(NAV_SNAPSHOT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as NavMainItem[]) : [];
} catch {
return [];
}
}
function saveNavSnapshot(items: NavMainItem[]): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(NAV_SNAPSHOT_KEY, JSON.stringify(items));
} catch {
// ignore
}
}
// Snapshot del último menú no vacío: evita parpadeo si filteredItems queda [] durante revalidación.
let lastNonEmptyItems = $state<NavMainItem[]>(loadNavSnapshot());
$effect(() => {
if (filteredItems.length > 0) {
lastNonEmptyItems = filteredItems;
saveNavSnapshot(filteredItems);
}
});
const visibleItems = $derived(
resolveVisibleNavItems(
filteredItems,
lastNonEmptyItems,
Boolean($authStore.user)
)
);
const sidebar = useSidebar();
@@ -121,9 +173,22 @@
</script>
<Sidebar.Group>
<Sidebar.GroupLabel>Anexo-76</Sidebar.GroupLabel>
<Sidebar.Menu id="dashboard-sidebar-nav" aria-label="Navegación principal">
{#each filteredItems as item (item.title)}
<Sidebar.GroupLabel class="flex items-center gap-2">
<span>Anexo-76</span>
{#if $permissionsRefreshing}
<span
class="size-1.5 shrink-0 animate-pulse rounded-full bg-primary"
title="Actualizando permisos"
aria-label="Actualizando permisos"
></span>
{/if}
</Sidebar.GroupLabel>
<Sidebar.Menu
id="dashboard-sidebar-nav"
aria-label="Navegación principal"
class={visibleItems.length === 0 && !$permissionsHydrated ? 'opacity-0' : ''}
>
{#each visibleItems as item (item.title)}
{#if item.items && item.items.length > 0}
{#if sidebar.state === 'collapsed'}
<!-- Sidebar Colapsado: Dropdown controlado por eventos estrictos -->

View File

@@ -49,13 +49,16 @@ class CompanyStore {
this._currentTenantId = newTenantId;
this._companies = preloadedCompanies;
// Si hay compañías y no hay una activa, seleccionar la correcta
// Si hay compañías y no hay una activa, seleccionar la correcta.
// IMPORTANTE: `setActiveCompany` es async (dispara syncCompanyPermissions);
// hay que `await` para que el caller (initialize → markPermissionsHydrated)
// no marque la hidratación antes de que los permisos del backend lleguen.
if (this._companies.length > 0 && !this._activeCompany) {
// Prioridad 1: ID pasado por parámetro (desde SSR/Cookie)
if (activeCompanyId) {
const company = this._companies.find(c => c.id === activeCompanyId);
if (company) {
this.setActiveCompany(company, true);
await this.setActiveCompany(company, true);
return;
}
}
@@ -66,13 +69,13 @@ class CompanyStore {
if (savedId) {
const company = this._companies.find(c => c.id === parseInt(savedId));
if (company) {
this.setActiveCompany(company, true); // silent=true para inicialización
await this.setActiveCompany(company, true); // silent=true para inicialización
return;
}
}
}
// Prioridad 3: Si no hay nada, seleccionar la primera
this.setActiveCompany(this._companies[0], true); // silent=true para inicialización
await this.setActiveCompany(this._companies[0], true); // silent=true para inicialización
}
return;
}
@@ -106,9 +109,11 @@ class CompanyStore {
this._companies = newCompanies;
// Si hay compañías y no hay una activa, seleccionar la primera
// Si hay compañías y no hay una activa, seleccionar la primera.
// `await` necesario para esperar a syncCompanyPermissions antes de
// que la inicialización del layout marque permissionsHydrated.
if (this._companies.length > 0 && !this._activeCompany) {
this.setActiveCompany(this._companies[0], true); // silent=true para inicialización
await this.setActiveCompany(this._companies[0], true); // silent=true para inicialización
}
} else {
console.error('Error loading companies:', response.error);

View File

@@ -19,8 +19,8 @@
SESSION_EXPIRED_EVENT
} from '$lib/session-manager';
import type { SessionExpiredDetail } from '$lib/session-manager';
import { authStore, markPermissionsHydrated } from '$lib/auth';
import { logout, getKeycloakInstance } from '$lib/auth';
import { authStore, loadSidebarPermsCache, markPermissionsHydrated, logout, getKeycloakInstance } from '$lib/auth';
import { get } from 'svelte/store';
import LicenseErrorScreen from '$lib/components/license-error-screen.svelte';
import { systemStore, type SystemType } from '$lib/stores/system.svelte';
import { workspaceAppsStore } from '$lib/stores/workspace-apps.svelte';
@@ -105,17 +105,44 @@
);
workspaceAppsStore.initialize(data.workspaceApps ?? [], data.appRouting ?? null);
// Re-sincronizar si data.user cambia (por ejemplo, tras invalidateAll o cambio de compañía)
$effect(() => {
// Dependencia explícita para que el effect reaccione al swap de data.user
data.user;
syncAuthStoreFromData();
systemStore.initialize(
(data.allowedSystems ?? []) as SystemType[],
(data.activeSystem ?? null) as string | null
);
workspaceAppsStore.initialize(data.workspaceApps ?? [], data.appRouting ?? null);
});
// Pre-popular permisos desde localStorage en el frame síncrono, antes del
// primer render, para que el sidebar muestre los ítems completos sin parpadeo.
// Esto cubre la segunda visita y tabs nuevas; la primera visita no tiene cache.
if (browser) {
const _preUserId = data.user?.sub ?? (data.user as any)?.id ?? null;
const _preCidRaw = (() => { try { return localStorage.getItem('activeCompanyId'); } catch { return null; } })();
const _preCid = _preCidRaw !== null ? parseInt(_preCidRaw) : NaN;
if (_preUserId && Number.isFinite(_preCid)) {
const _cached = loadSidebarPermsCache(_preUserId, _preCid);
if (_cached) {
const _u = get(authStore).user;
if (_u) {
authStore.setUser({
..._u,
permissions: _cached.permissions,
roles: Array.from(new Set([...(_u.roles ?? []), ..._cached.roles])),
allowedSystems: _cached.allowedSystems.length > 0
? (_cached.allowedSystems as SystemType[])
: _u.allowedSystems,
tenantId: _cached.tenantId ?? _u.tenantId
});
markPermissionsHydrated();
}
}
}
}
// NOTA: el $effect que re-corría syncAuthStoreFromData() en cada cambio de
// data.user fue eliminado porque destruía los permisos del store en cada
// navegación. El SSR no es fuente de verdad para `permissions`/`roles`
// (los carga el cliente vía syncCompanyPermissions en companyStore), así
// que reaplicar `data.user.permissions ?? []` los reseteaba a `[]`.
//
// Ahora: la sincronización síncrona de arriba cubre la hidratación inicial
// (SSR + refresh). El refresco ante un cambio de compañía se hace de forma
// explícita en `handleCompanyChange` más abajo, donde sí cambian sistemas
// y apps. La identidad del usuario no cambia entre rutas ni entre
// compañías, así que no requiere refresco reactivo.
// ── Manejar expiración de sesión ────────────────────────────────────────
function handleSessionExpired(e: Event) {
@@ -165,6 +192,17 @@
// ── Escuchar cambios de compañía y recargar datos ─────────────────────
const handleCompanyChange = async () => {
await invalidateAll();
// Re-poblar sistemas y apps con la nueva data tras invalidateAll.
// Antes lo hacía el $effect que reaccionaba a data.user, ahora se
// hace de forma explícita solo en el cambio de compañía (única vez
// que esos datos realmente cambian). No tocamos authStore aquí:
// identidad no cambia entre compañías, y los permisos los maneja
// syncCompanyPermissions que se dispara dentro de setActiveCompany.
systemStore.initialize(
(data.allowedSystems ?? []) as SystemType[],
(data.activeSystem ?? null) as string | null
);
workspaceAppsStore.initialize(data.workspaceApps ?? [], data.appRouting ?? null);
await goto(`${page.url.pathname}${page.url.search}${page.url.hash}`, { invalidateAll: true });
};
window.addEventListener('companyChanged', handleCompanyChange);

View File

@@ -28,12 +28,12 @@
const HUB_MODE = import.meta.env.VITE_HUB_MODE === 'true';
// 🛡️ Seguridad y Permisos
import { currentUser, userHasPermission } from '$lib/auth';
import { currentUser, userHasPermission, userIsAdmin } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
const canView = $derived(userHasPermission($currentUser, 'help_center.view'));
// Solo los ADMIN de Keycloak pueden crear/editar/borrar en el Hub
const isAdmin = $derived($currentUser?.roles?.includes('admin') || false);
// Acceso de administración (Hub admin, admin de realm o super_admin local)
const isAdmin = $derived(userIsAdmin($currentUser));
const canCreate = $derived(HUB_MODE && isAdmin);
const canEdit = $derived(HUB_MODE && isAdmin);
const canDelete = $derived(HUB_MODE && isAdmin);

View File

@@ -24,7 +24,7 @@
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { getAccessTokenFromDocument } from '$lib/access-token-cookie-browser';
import { currentUser } from '$lib/auth';
import { currentUser, permissionsHydrated } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import {
userCanViewInvoiceListPage,
@@ -62,7 +62,8 @@
Truck,
Route,
FileSpreadsheet,
Building2
Building2,
LoaderCircle
} from 'lucide-svelte';
import DetailsDialog from '$lib/components/dashboard/invoices/details-dialog.svelte';
import DeleteDialog from '$lib/components/dashboard/invoices/delete-dialog.svelte';
@@ -1116,7 +1117,7 @@
? `/dashboard/invoices/edit/new?${queryString}`
: '/dashboard/invoices/edit/new';
window.location.href = url;
void goto(url);
}
function handleEditSelected() {
@@ -1126,7 +1127,7 @@
const url = queryString
? `/dashboard/invoices/edit/${selectedInvoice.id}?${queryString}`
: `/dashboard/invoices/edit/${selectedInvoice.id}`;
window.location.href = url;
void goto(url);
} else {
toast.info(m.invoice_list_toasts_select_invoice_to_edit());
}
@@ -1601,7 +1602,14 @@
}
</script>
{#if !canViewInvoiceSection}
{#if !$permissionsHydrated}
<div
class="flex h-[calc(100svh-4rem)] flex-col items-center justify-center gap-3 p-6 text-muted-foreground group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]"
>
<LoaderCircle class="h-8 w-8 animate-spin text-primary" />
<p class="text-sm">Verificando permisos…</p>
</div>
{:else if !canViewInvoiceSection}
<div
class="flex h-[calc(100svh-4rem)] flex-col p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]"
>

View File

@@ -136,7 +136,7 @@ let filters = $state({
return;
}
if (selectedId) {
window.location.href = `/dashboard/pedimentos/edit/${selectedId}`;
void goto(`/dashboard/pedimentos/edit/${selectedId}`);
}
}
@@ -463,7 +463,7 @@ let filters = $state({
toast.error('No tiene permiso para crear pedimentos');
return;
}
window.location.href = '/dashboard/pedimentos/edit/new';
void goto('/dashboard/pedimentos/edit/new');
}
function handleSuccess() {