Appearance
Frontend CASL Integration
The panel uses CASL (@casl/ability + @casl/react) to enforce authorization in the browser. Abilities are fetched from the API on login, stored in a React Context, and used reactively to filter sidebar items, gate routes, and hide UI elements.
Architecture Overview
selectedOrgId (Zustand / AgencyProvider)
│
▼
useMyAbilities (TanStack Query)
GET /identity/user/my-abilities
│
▼
AbilityProvider (React Context)
├── createMongoAbility(rules) on mount
├── ability.update(rules) on refetch / org change
└── AbilityContext.Provider value={ability}
│
├── Sidebar → filterTabsByAbility(tabs, ability)
├── <Can I="action" a="subject"> in views
└── useCan(action, subject) for imperative checksDefault-deny: while abilities are loading or if the request fails, the MongoAbility instance holds empty rules. This means all gated items are hidden until abilities resolve — no flash of forbidden content.
Where AbilityProvider Lives in the Component Tree
AbilityProvider is mounted inside ProtectedRoute, wrapping <Outlet />:
main.tsx
QueryClientProvider
AgencyProvider
RouterProvider (App)
NuqsAdapter
PublicRoute | ProtectedRoute
ProtectedRoute ← auth check + org load
AbilityProvider ← HERE: fetches abilities, provides context
Dashboard
Sidebar (filters items by ability)
Outlet (protected views)This placement guarantees:
- Auth context is always available —
ProtectedRoutealready assertsisAuthenticated. selectedOrgIdis always set — org is resolved beforeAbilityProviderrenders.- All protected views (settings, agents, etc.) are within the ability context.
- No changes to
main.tsxorApp.tsxwere required.
useMyAbilities — Data Fetching Hook
Located at apps/panel/src/features/dashboard/hooks/use-my-abilities.ts.
ts
import {
useMyAbilities,
MY_ABILITIES_QUERY_KEY,
} from '@/features/dashboard/hooks/use-my-abilities';
const { data, isLoading, isError } = useMyAbilities();
// data: IMyAbilitiesResponse | undefined
// data.rules: IRawPermission[]Key configuration:
| Option | Value | Rationale |
|---|---|---|
| Query key | ['identity', 'user', 'my-abilities', selectedOrgId] | Org switch triggers automatic refetch |
enabled | !!selectedOrgId | Disabled when no org is selected |
staleTime | 2 minutes | Balances freshness with request volume |
refetchOnWindowFocus | true | Catches policy changes made in another tab |
gcTime | 5 minutes | Keeps cached rules in memory for fast org switch |
Export MY_ABILITIES_QUERY_KEY for manual invalidation:
ts
import { MY_ABILITIES_QUERY_KEY } from '@/features/dashboard/hooks/use-my-abilities';
// After an admin changes a policy, invalidate the abilities cache:
queryClient.invalidateQueries({ queryKey: MY_ABILITIES_QUERY_KEY });AbilityProvider — Context Provider
Located at apps/panel/src/infrastructure/router/ability-provider.tsx.
The provider creates a single MongoAbility instance at mount time and calls ability.update(rules) whenever the fetched rules change (org switch, window focus refetch, etc.). @casl/react subscribes to ability.update() internally and re-renders all consumers automatically.
tsx
import { AbilityProvider } from '@/infrastructure/router/ability-provider';
// Usage (already wired inside ProtectedRoute):
<AbilityProvider>
<Outlet />
</AbilityProvider>;Loading and error states — both use empty rules ([]), so the ability instance denies all gated checks until abilities resolve. Ungated items (no requiredAbility) are always visible.
AbilityContext — Direct Context Access
Located at apps/panel/src/shared/lib/ability-context.ts.
ts
import { AbilityContext, Can } from '@/shared/lib/ability-context';
import { useAbility } from '@casl/react';
// Anywhere inside AbilityProvider:
const ability = useAbility(AbilityContext);
const canReadChat = ability.can('read', 'ai.chat');The AbilityContext is a standard React context holding a MongoAbility instance. It is created from @casl/react's createContextualCan and createMongoAbility — fully compatible with the <Can> component exported from the same module.
useCan(action, subject) — Imperative Hook
Located at apps/panel/src/shared/hooks/use-can.ts.
Returns a boolean — true if the current user has the specified ability.
tsx
import { useCan } from '@/shared/hooks/use-can';
function MyComponent() {
const canManageAgents = useCan('manage', 'ai.agent');
const canReadFinances = useCan('read', 'finances.dashboard');
return (
<div>
{canManageAgents && <CreateAgentButton />}
{canReadFinances && <FinanceSummary />}
</div>
);
}<Can> Component — Declarative Usage
The Can component from @casl/react is exported pre-bound to AbilityContext from apps/panel/src/shared/lib/ability-context.ts.
tsx
import { Can } from '@/shared/lib/ability-context';
function SettingsPage() {
return (
<div>
{/* Visible only if ability.can('read', 'identity.user') */}
<Can I="read" a="identity.user">
<UserListTable />
</Can>
{/* Deny rule example: visible if user CANNOT delete agents */}
<Can not I="delete" a="ai.agent">
<span>You don't have delete access</span>
</Can>
</div>
);
}Props mirror the @casl/react API:
| Prop | Description |
|---|---|
I | The action string (e.g. read, manage) |
a | The subject string (e.g. ai.chat, platform.admin) |
not | Inverts the check — renders children when the ability is denied |
passThrough | Renders children regardless, passing a boolean allowed prop |
TABS_CONFIG.requiredAbility — Sidebar Gating
The TabConfig type (in apps/panel/src/features/dashboard/config/tabs.config.ts) has an optional requiredAbility field:
ts
export interface IRequiredAbility {
action: string;
subject: string;
}
export interface TabConfig {
id: TabId;
label: string;
icon: LucideIcon;
requiredAbility?: IRequiredAbility; // undefined = always shown
}How to gate a sidebar item:
ts
// apps/panel/src/features/dashboard/config/tabs.config.ts
export const TABS_CONFIG: Record<TabId, TabConfig> = {
home: {
id: 'home',
label: 'Inicio',
icon: HomeIcon,
// No requiredAbility — always visible
},
finances: {
id: 'finances',
label: 'Finanzas',
icon: TrendingUpIcon,
requiredAbility: { action: 'read', subject: 'finances.dashboard' },
},
platformAdmin: {
id: 'platformAdmin',
label: 'Ajustes Plataforma',
icon: ShieldIcon,
requiredAbility: { action: 'manage', subject: 'platform.admin' },
},
};Sidebar filter logic (pure function in sidebar.tsx):
ts
export function filterTabsByAbility(tabs: TabConfig[], ability: MongoAbility): TabConfig[] {
return tabs.filter((tab) =>
tab.requiredAbility
? ability.can(tab.requiredAbility.action, tab.requiredAbility.subject)
: true,
);
}The sidebar calls useAbility(AbilityContext) and passes the result to filterTabsByAbility. Because useAbility subscribes to ability.update(), the sidebar re-renders automatically when rules change.
Query Invalidation — When Abilities Refetch
Abilities refetch automatically in three cases:
- Org switch:
selectedOrgIdchanges → query key changes → TanStack Query fetches for new org. - Window focus:
refetchOnWindowFocus: true+staleTime: 2min— if the cached data is older than 2 minutes, a focus event triggers a background refetch. - Manual invalidation: call
queryClient.invalidateQueries({ queryKey: MY_ABILITIES_QUERY_KEY })after policy/role mutations (e.g., after attaching a policy to a role in the Settings tab).
Default-Deny Behavior During Loading and Error
| State | ability.rules | Effect |
|---|---|---|
isLoading: true | [] (empty) | All gated sidebar items hidden; ungated items visible |
isError: true | [] (empty) | Same as loading — gated items hidden; app remains functional |
data resolved | Rules from API | Gated items appear if ability allows; hidden otherwise |
selectedOrgId: null | [] (empty, query disabled) | All gated items hidden |
This implements a fail-closed (restrictive) posture: the user sees fewer items than they might have access to during the brief loading window, but never sees items they shouldn't.
Behavior Scenarios
Org Switch
- User selects a different org in the header.
selectedOrgIdupdates in the Zustand store.useMyAbilitiesquery key includesselectedOrgId→ TanStack Query automatically fires a new request for the new org.- While fetching,
AbilityProvidercallsability.update([])→ sidebar shows only ungated items. - Response arrives →
ability.update(newRules)→ sidebar re-renders with new org's items.
Admin Changes a Policy
- Admin attaches a new policy to a role in the Settings → Roles y Permisos tab.
- The mutation hook calls
queryClient.invalidateQueries({ queryKey: MY_ABILITIES_QUERY_KEY }). - TanStack Query refetches abilities in the background.
- The updated rules appear in the sidebar without a page reload.
Window Refocus
- Admin opens a second browser tab, modifies a user's policies, closes the tab.
- User refocuses the original tab after more than 2 minutes of inactivity.
refetchOnWindowFocus: true+ stale cache triggers a background fetch.- New abilities load silently, sidebar updates.
Related
- My Abilities Endpoint — API contract and CQRS flow
- RBAC and Custom Roles — how policies and roles are managed
- CASL Authorization — the server-side authorization engine
- Settings — Políticas CASL — panel UI for managing roles and policies