feature/optimizacion-de-permisos
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]> = {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]"
|
||||
>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user