Add permissions hydration handling to improve user experience and prevent access flash

- Introduced `permissionsHydrated` writable store to track if RBAC permissions are loaded for the active company.
- Added `markPermissionsHydrated` function to set the hydration state.
- Updated dashboard components to wait for permissions to be hydrated before rendering restricted content, enhancing user experience by avoiding "Access Denied" flashes.
- Refactored permission checks in `pedimento-permissions.ts` to consistently use `userHasPermission` for clarity and maintainability.
This commit is contained in:
2026-05-15 17:32:08 -05:00
parent 4b22fa85e2
commit 02b3ae8334
5 changed files with 65 additions and 40 deletions

View File

@@ -150,6 +150,19 @@ export const authStore = createAuthStore();
export const isAuthenticated = derived(authStore, ($a) => $a.isAuthenticated);
export const currentUser = derived(authStore, ($a) => $a.user);
/**
* Indica si los permisos RBAC del usuario para la compañía activa ya se hidrataron
* en cliente (vía {@link syncCompanyPermissions} o tras la inicialización del
* dashboard si no hay compañía). Las pantallas que muestran 403 según permisos
* deben esperar a que esto sea `true` antes de decidir, para evitar el flash
* de "Acceso restringido" en el primer render.
*/
export const permissionsHydrated = writable<boolean>(false);
export function markPermissionsHydrated(): void {
permissionsHydrated.set(true);
}
/**
* Verifica si el usuario tiene un permiso específico.
* `user.permissions` debe incluir códigos de la app (p. ej. `user.view`); suelen
@@ -441,6 +454,10 @@ export async function syncCompanyPermissions(companyId: number): Promise<void> {
});
} catch (e) {
console.warn('[auth] syncCompanyPermissions:', e);
} finally {
// Levanta el flag aunque la sync falle: si no se pudo, las pantallas
// quedan con lo que vino del SSR y deben dejar de mostrar el loader.
permissionsHydrated.set(true);
}
}

View File

@@ -1,34 +1,29 @@
/**
* Permisos de pedimentos — códigos `pedimentos_mgmt.*` (seed / API).
*
* - **Ver listado:** `userHasPermission` (incluye comodín de rol Keycloak `admin` como en otras pantallas).
* - **Crear / editar / borrar (mutaciones):** solo códigos en `user.permissions` (tras `syncCompanyPermissions`).
* Todas las comprobaciones usan `userHasPermission`, que respeta el comodín de
* rol Keycloak `admin` (mismo patrón que `invoice-permissions.ts`).
*
* **Alta de pedimento (`Nuevo` / `/edit/new`):** requiere `pedimentos_mgmt.create`.
*/
import type { User } from '$lib/auth';
import { userHasPermission } from '$lib/auth';
function hasAssignedCode(user: User | null, code: string): boolean {
if (!user) return false;
return user.permissions.includes(code);
}
export function canViewPedimentosList(user: User | null): boolean {
return userHasPermission(user, 'pedimentos_mgmt.view');
}
/** Permiso de API/rol para crear pedimento. */
export function canCreatePedimento(user: User | null): boolean {
return hasAssignedCode(user, 'pedimentos_mgmt.create');
return userHasPermission(user, 'pedimentos_mgmt.create');
}
export function canEditPedimento(user: User | null): boolean {
return hasAssignedCode(user, 'pedimentos_mgmt.edit');
return userHasPermission(user, 'pedimentos_mgmt.edit');
}
export function canDeletePedimento(user: User | null): boolean {
return hasAssignedCode(user, 'pedimentos_mgmt.delete');
return userHasPermission(user, 'pedimentos_mgmt.delete');
}
/** Botón "Nuevo Pedimento" y navegación a `/edit/new` — requiere permiso create. */

View File

@@ -18,7 +18,7 @@
SESSION_EXPIRED_EVENT
} from '$lib/session-manager';
import type { SessionExpiredDetail } from '$lib/session-manager';
import { authStore } from '$lib/auth';
import { authStore, markPermissionsHydrated } from '$lib/auth';
import { logout, getKeycloakInstance } from '$lib/auth';
import LicenseErrorScreen from '$lib/components/license-error-screen.svelte';
@@ -140,7 +140,12 @@
await companyStore.initialize(data.companies, activeCompanyId);
// Releer en cliente: el SSR puede haber quedado desfasado respecto al provisionamiento.
await companyStore.loadCompanies(undefined, activeCompanyId);
// Garantiza que el flag se levante incluso si no hay compañía activa
// (en ese caso `syncCompanyPermissions` nunca corre).
markPermissionsHydrated();
})();
} else {
markPermissionsHydrated();
}
// ── Escuchar cambios de compañía y recargar datos ─────────────────────

View File

@@ -12,7 +12,7 @@
import { browser } from '$app/environment';
import { getAccessTokenFromDocument } from '$lib/access-token-cookie-browser';
import { companyStore } from '$lib/stores/company.svelte';
import { Edit, Send, Plus, Trash2, RefreshCw } from 'lucide-svelte';
import { Edit, Send, Plus, Trash2, RefreshCw, LoaderCircle } from 'lucide-svelte';
import { obtenerAtajosListaPedimento } from '$lib/config/shortcuts/dashboard/pedimentos/list';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { goto } from '$app/navigation';
@@ -22,7 +22,7 @@
import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import { get } from 'svelte/store';
import { currentUser } from '$lib/auth';
import { currentUser, permissionsHydrated } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import {
canViewPedimentosList,
@@ -36,8 +36,6 @@
const canViewList = $derived(canViewPedimentosList($currentUser));
const canNuevoPedimentoAction = $derived(canOpenNuevoPedimentoForm($currentUser));
const canEditPedimentoAction = $derived(canEditPedimento($currentUser));
const canDeletePedimentoAction = $derived(canDeletePedimento($currentUser));
// Estado para filtros
let filters = $state({
@@ -509,7 +507,14 @@ let filters = $state({
const columns = createColumns(handleSuccess);
</script>
{#if !canViewList}
{#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 !canViewList}
<div
class="flex h-[calc(100svh-4rem)] flex-col p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]"
>
@@ -620,28 +625,24 @@ let filters = $state({
<div class="mx-auto max-w-[1400px] px-4 py-4">
<!-- Botones de acción -->
<div class="flex justify-end gap-2">
{#if canEditPedimentoAction}
<Button
variant="outline"
size="sm"
onclick={handleEditSelected}
disabled={!canEditSelected}
>
<Edit size={16} class="mr-1" />
Editar
</Button>
{/if}
{#if canDeletePedimentoAction}
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!canDeleteSelected}
>
<Trash2 size={16} class="mr-1" />
Borrar
</Button>
{/if}
<Button
variant="outline"
size="sm"
onclick={handleEditSelected}
disabled={!canEditSelected}
>
<Edit size={16} class="mr-1" />
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!canDeleteSelected}
>
<Trash2 size={16} class="mr-1" />
Borrar
</Button>
<Button
variant="outline"
size="sm"

View File

@@ -85,7 +85,7 @@
import { companyStore } from '$lib/stores/company.svelte';
import PrerequisitesModal from '$lib/components/dashboard/PrerequisitesModal.svelte';
import { currentUser } from '$lib/auth';
import { currentUser, permissionsHydrated } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import { canAccessPedimentoEditorPage } from '$lib/permissions/pedimento-permissions';
@@ -1100,7 +1100,14 @@
}
</script>
{#if !canUsePedimentoEditor}
{#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 !canUsePedimentoEditor}
<div
class="flex h-[calc(100svh-4rem)] flex-col p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]"
>