Frontend ACL with permission codes#

The backend’s @Permission and ACL extension control what the server allows. The frontend’s job is to hide controls the user can’t use — buttons, menu items, grid actions — so they don’t click something that returns 403. This page shows the full lifecycle: login → cache → hook → conditional rendering → refresh.

Permission code config#

Centralise every code so they’re searchable and rename-safe:

// src/config/Permissions.ts
export const Permission = {
  project: {
    GET_ALL: 'PRJ001',
    CREATE:  'PRJ002',
    UPDATE:  'PRJ003',
    DELETE:  'PRJ004',
    EXPORT:  'PRJ005',
    APPROVE: 'PRJ006',
  },
  invoice: {
    GET_ALL: 'INV001',
    CREATE:  'INV002',
    APPROVE: 'INV003',
  },
};

These codes match the values stored in the backend’s ACL tables. The backend returns them at login (or via a dedicated /acl/permissions endpoint).

Caching after login#

After a successful login, stash the user’s codes in localStorage. A topic subscription lets any component force a re-read without a full page reload.

// src/auth/AclCache.ts
import { topic } from '@palmyralabs/ts-utils';

const STORAGE_KEY = 'palmyra.acl.codes';

let cache: string[] = readFromStorage();

topic.subscribe('resetAcl', () => { cache = readFromStorage(); });

export function setAclCodes(codes: string[]) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(codes));
  cache = codes;
  topic.publish('resetAcl', {});
}

export function clearAclCodes() {
  localStorage.removeItem(STORAGE_KEY);
  cache = [];
}

function readFromStorage(): string[] {
  try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); }
  catch { return []; }
}

export function hasAccess(code: string): boolean {
  return cache.includes(code);
}

Call setAclCodes(response.aclCodes) in your login success handler; call clearAclCodes() on logout.

Hook for components#

// src/auth/useAclAccess.ts
import { useSyncExternalStore } from 'react';
import { hasAccess } from './AclCache';

// React subscribes to the topic indirectly — the cache is the store.
export function useAclAccess() {
  return { can: (code: string) => hasAccess(code) };
}

Conditional rendering#

import { useAclAccess } from '../../auth/useAclAccess';
import { Permission } from '../../config/Permissions';

function ProjectGridPage() {
  const { can } = useAclAccess();

  return (
    <SummaryGrid
      columns={projectColumns}
      options={{ endPoint: '/project' }}
      gridRef={gridRef}
      getPluginOptions={() => ({
        addText:    can(Permission.project.CREATE) ? 'New Project' : '',
        onNewClick: can(Permission.project.CREATE) ? open : undefined,
        export:     { visible: can(Permission.project.EXPORT) },
      })}
    />
  );
}

Gating row-level actions#

Inside a custom DataGridControls or a cellRenderer:

const ActionCell = ({ row }: { row: any }) => {
  const { can } = useAclAccess();
  return (
    <Group gap="xs">
      {can(Permission.project.UPDATE) && (
        <Button size="xs" variant="subtle" onClick={() => edit(row)}>Edit</Button>
      )}
      {can(Permission.project.DELETE) && (
        <Button size="xs" variant="subtle" color="red" onClick={() => remove(row)}>Delete</Button>
      )}
      {can(Permission.project.APPROVE) && row.status === 'SUBMITTED' && (
        <Button size="xs" variant="subtle" color="green" onClick={() => approve(row)}>Approve</Button>
      )}
    </Group>
  );
};

Remember: the UI is not the security boundary#

A hidden button doesn’t stop a crafted curl. The server enforces access — @Permission + PalmyraPermissionEvaluator (or your own PermissionEvaluator). The frontend ACL hook only avoids showing controls that the server would reject.

See also: @Permission, PalmyraPermissionEvaluator.