Skip to content

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 checks

Default-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:

  1. Auth context is always availableProtectedRoute already asserts isAuthenticated.
  2. selectedOrgId is always set — org is resolved before AbilityProvider renders.
  3. All protected views (settings, agents, etc.) are within the ability context.
  4. No changes to main.tsx or App.tsx were 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:

OptionValueRationale
Query key['identity', 'user', 'my-abilities', selectedOrgId]Org switch triggers automatic refetch
enabled!!selectedOrgIdDisabled when no org is selected
staleTime2 minutesBalances freshness with request volume
refetchOnWindowFocustrueCatches policy changes made in another tab
gcTime5 minutesKeeps 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 booleantrue 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:

PropDescription
IThe action string (e.g. read, manage)
aThe subject string (e.g. ai.chat, platform.admin)
notInverts the check — renders children when the ability is denied
passThroughRenders 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:

  1. Org switch: selectedOrgId changes → query key changes → TanStack Query fetches for new org.
  2. Window focus: refetchOnWindowFocus: true + staleTime: 2min — if the cached data is older than 2 minutes, a focus event triggers a background refetch.
  3. 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

Stateability.rulesEffect
isLoading: true[] (empty)All gated sidebar items hidden; ungated items visible
isError: true[] (empty)Same as loading — gated items hidden; app remains functional
data resolvedRules from APIGated 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

  1. User selects a different org in the header.
  2. selectedOrgId updates in the Zustand store.
  3. useMyAbilities query key includes selectedOrgId → TanStack Query automatically fires a new request for the new org.
  4. While fetching, AbilityProvider calls ability.update([]) → sidebar shows only ungated items.
  5. Response arrives → ability.update(newRules) → sidebar re-renders with new org's items.

Admin Changes a Policy

  1. Admin attaches a new policy to a role in the Settings → Roles y Permisos tab.
  2. The mutation hook calls queryClient.invalidateQueries({ queryKey: MY_ABILITIES_QUERY_KEY }).
  3. TanStack Query refetches abilities in the background.
  4. The updated rules appear in the sidebar without a page reload.

Window Refocus

  1. Admin opens a second browser tab, modifies a user's policies, closes the tab.
  2. User refocuses the original tab after more than 2 minutes of inactivity.
  3. refetchOnWindowFocus: true + stale cache triggers a background fetch.
  4. New abilities load silently, sidebar updates.