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.