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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 ─────────────────────
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)]"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user