Skip to main content

Developer RBAC Guide

This guide covers how to implement permission-based access control in the HB Life Science frontend application. It's intended for developers adding new features or modifying existing ones.


Quick Start​

Protect a Button​

import { ActionGuard } from '@/components/guards/ActionGuard';

<ActionGuard resource="personnel" action="create">
<Button>Add Personnel</Button>
</ActionGuard>

Check Permission in Code​

import { useActionPermissions } from '@/hooks/useActionPermissions';

function MyComponent() {
const { can } = useActionPermissions();

if (can('study', 'delete')) {
// User can delete studies
}
}

Core Concepts​

Resources​

Resources are the entities that users can access. They match the CTMS Resource records in Frappe.

ResourceDescription
studyStudy records
subjectSubject/participant records
personnelStudy personnel assignments
crfCRF form designs
crf_entrySubject CRF data entries
demographicsSubject demographics
vitalsVital signs
adverse_eventAdverse events
Flexible Resources

You can use any string as a resource. If it exists in Frappe, permissions will work automatically. No TypeScript changes needed!

Actions​

Actions are operations that can be performed on resources:

ActionUse Case
createAdd/New buttons
readView/List access
updateEdit buttons
deleteDelete buttons
bulk_deleteBulk delete buttons
exportExport buttons
importImport buttons

Using ActionGuard​

The ActionGuard component is the primary way to protect UI elements.

Basic Usage​

import { ActionGuard } from '@/components/guards/ActionGuard';

// Hide button if no permission
<ActionGuard resource="study" action="create">
<Button>Add Study</Button>
</ActionGuard>

With Fallback​

// Show alternative content when no permission
<ActionGuard
resource="study"
action="delete"
fallback={<span>Contact admin to delete</span>}
>
<Button variant="destructive">Delete Study</Button>
</ActionGuard>

With Tooltip​

// Show disabled button with tooltip for restricted users
<ActionGuard
resource="study"
action="update"
showTooltip
tooltipMessage="You need Study Designer role to edit studies"
>
<Button>Edit Study</Button>
</ActionGuard>

Props Reference​

PropTypeDefaultDescription
resourcestringrequiredThe resource to check (e.g., "study", "personnel")
actionstringrequiredThe action to check (e.g., "create", "update")
fallbackReactNodenullContent to show when no permission
showTooltipbooleanfalseShow disabled button with tooltip
tooltipMessagestringautoCustom tooltip message
disabledbooleanfalseAdditional disabled state from parent

Using the Hook​

For more control, use the useActionPermissions hook directly.

Basic Permission Check​

import { useActionPermissions } from '@/hooks/useActionPermissions';

function StudyActions() {
const { can } = useActionPermissions();

return (
<div>
{can('study', 'create') && (
<Button>Add Study</Button>
)}
{can('study', 'update') && (
<Button>Edit Study</Button>
)}
{can('study', 'delete') && (
<Button variant="destructive">Delete Study</Button>
)}
</div>
);
}

Convenience Methods​

const { 
can, // General check: can(resource, action)
canCreate, // Shorthand: canCreate(resource)
canUpdate, // Shorthand: canUpdate(resource)
canDelete, // Shorthand: canDelete(resource)
canBulkDelete, // Shorthand: canBulkDelete(resource)
canExport, // Shorthand: canExport(resource)
canImport, // Shorthand: canImport(resource)
isReadOnly, // Is user in read-only role?
canWrite, // Does user have any write access?
} = useActionPermissions();

// Example usage
if (canCreate('study')) {
// Show create button
}

if (isReadOnly) {
// Show read-only notice
}

Check Multiple Permissions​

const { can } = useActionPermissions();

// Build column definition based on permissions
const columns = useMemo(() => {
const cols = [
{ id: 'name', header: 'Name' },
{ id: 'status', header: 'Status' },
];

// Only add actions column if user can edit or delete
if (can('study', 'update') || can('study', 'delete')) {
cols.push({ id: 'actions', header: 'Actions' });
}

return cols;
}, [can]);

Common Patterns​

Protect Row Actions​

function ActionsCell({ row }) {
const { can } = useActionPermissions();

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<ActionGuard resource="study" action="update">
<DropdownMenuItem onClick={() => handleEdit(row)}>
Edit
</DropdownMenuItem>
</ActionGuard>
<ActionGuard resource="study" action="delete">
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDelete(row)}
>
Delete
</DropdownMenuItem>
</ActionGuard>
</DropdownMenuContent>
</DropdownMenu>
);
}

Protect Empty State Actions​

function EmptyState() {
const { can } = useActionPermissions();

return (
<div className="text-center py-10">
<p>No studies found</p>
{can('study', 'create') && (
<Button className="mt-4">
Create Your First Study
</Button>
)}
</div>
);
}

Protect Bulk Actions​

function BulkActions({ selectedRows }) {
return (
<div className="flex gap-2">
<ActionGuard resource="study" action="export">
<Button variant="outline">
Export Selected ({selectedRows.length})
</Button>
</ActionGuard>
<ActionGuard resource="study" action="bulk_delete">
<Button variant="destructive">
Delete Selected ({selectedRows.length})
</Button>
</ActionGuard>
</div>
);
}

Conditional Column with Checkboxes​

const columns = useMemo(() => {
const cols = [];

// Only show checkbox column if user can bulk delete
if (can('study', 'bulk_delete')) {
cols.push({
id: 'select',
header: ({ table }) => <Checkbox ... />,
cell: ({ row }) => <Checkbox ... />,
});
}

// Add other columns...
cols.push({ id: 'name', header: 'Name' });

return cols;
}, [can]);

Best Practices​

1. Use Correct Resource Names​

Match the resource name to what's defined in Frappe:

// ✅ Good - matches Frappe resource
<ActionGuard resource="personnel" action="create">

// ❌ Bad - using outdated resource name
<ActionGuard resource="user" action="create">

2. Protect All Entry Points​

Buttons can appear in multiple places. Protect all of them:

// 1. Above the grid
<ActionGuard resource="study" action="create">
<Button>Add Study</Button>
</ActionGuard>

// 2. Empty state
{can('study', 'create') && <Button>Create First Study</Button>}

// 3. Row actions menu
<ActionGuard resource="study" action="update">
<DropdownMenuItem>Edit</DropdownMenuItem>
</ActionGuard>

3. Don't Duplicate Permission Logic​

// ✅ Good - single source of truth
const canEdit = can('study', 'update');
return canEdit && <Button>Edit</Button>;

// ❌ Bad - hardcoding role checks
const canEdit = userRole === 'Study Designer' || userRole === 'Admin';

4. Use ActionGuard for UI, Backend for Security​

// Frontend: UX only (hide button)
<ActionGuard resource="study" action="delete">
<Button onClick={handleDelete}>Delete</Button>
</ActionGuard>

// Backend: Security (validate permission)
// API endpoint also checks permission before deleting

Anti-Patterns to Avoid​

Hardcoded Role Checks for UI Control

Never use hardcoded role checks to control button visibility. This bypasses the dynamic permission system and breaks when permissions change in Frappe.

❌ Wrong: Hardcoded Role Check​

// DON'T DO THIS - bypasses dynamic permissions
import { ROLES } from '@/lib/auth/rbac/roles';

function SitesClient() {
const { roles } = useAuth();

// ❌ This only checks one role, ignoring the permission matrix
const isStudyCoordinator = roles.includes(ROLES.STUDY_COORDINATOR);

return (
<SitesTable
readOnly={isStudyCoordinator} // ❌ Wrong!
/>
);
}

Problems with this approach:

  • Only checks Study Coordinator, ignores Study Designer and other roles
  • Bypasses the dynamic permission matrix from Frappe CTMS
  • Requires code changes when role permissions change
  • Inconsistent behavior across the application

✅ Correct: Dynamic Permission Check​

// DO THIS - uses dynamic permission system
import { useActionPermissions } from '@/hooks/useActionPermissions';

function SitesClient() {
const { canCreate, canUpdate, canDelete } = useActionPermissions();

// ✅ Check if user has ANY write permission for sites
const hasWritePermission = canCreate('site') || canUpdate('site') || canDelete('site');
const isReadOnly = !hasWritePermission;

return (
<SitesTable
readOnly={isReadOnly} // ✅ Correct!
/>
);
}

When Role Checks ARE Appropriate​

Role checks are still valid for these specific use cases:

Use CaseExampleWhy It's OK
Data FilteringStudy Coordinators only see subjects from assigned studiesBusiness logic, not UI control
Feature TogglesOnly Admins see AI Chat widgetFeature visibility, not action permissions
Dashboard RoutingDifferent home pages per roleNavigation, not button visibility
// ✅ OK - Role check for DATA FILTERING (not UI control)
const isStudyCoordinator = roles.includes(ROLES.STUDY_COORDINATOR);

if (isStudyCoordinator && profile?.email) {
// Fetch only subjects from assigned studies
const patients = await fetchSubjectsForStudyCoordinator(profile.email);
} else {
// Fetch all subjects
const patients = await fetchAllSubjects();
}

// ✅ OK - Role check for FEATURE TOGGLE
const isPlatformAdmin = roles.includes(ROLES.ADMIN);
if (isPlatformAdmin) {
// Show AI chat widget (feature, not action)
}

Summary: Permission Check Decision Tree​

Need to show/hide a button?
├── YES → Use useActionPermissions or ActionGuard
│ const { can } = useActionPermissions();
│ {can('resource', 'action') && <Button>...</Button>}
│
Need to filter data based on role?
├── YES → Role check is acceptable
│ if (roles.includes(ROLES.STUDY_COORDINATOR)) { ... }
│
Need to show/hide a feature (not an action)?
├── YES → Role check is acceptable
│ if (roles.includes(ROLES.ADMIN)) { showFeature(); }

Adding New Resources​

When Frappe adds a new resource, no frontend code changes are required for permissions to work.

Step 1: Define in Frappe​

Create CTMS Resource and CTMS Permission records.

Step 2: Use in Frontend​

// Works immediately - no TypeScript changes needed
<ActionGuard resource="new_resource" action="create">
<Button>Add New Resource</Button>
</ActionGuard>

Optional: Add to Known Types​

For better autocomplete, add to KnownResource:

// src/lib/auth/rbac/actions.ts
export type KnownResource =
| 'study'
| 'subject'
| 'new_resource' // Add here for autocomplete
| ...;

Debugging​

Check Current Permissions​

import { usePermissionContext } from '@/contexts/permission-context';

function DebugPanel() {
const { permissionMatrix, isUsingFallback, isLoaded } = usePermissionContext();

console.log('Using fallback:', isUsingFallback);
console.log('Loaded:', isLoaded);
console.log('Matrix:', permissionMatrix);

return null;
}

Check Why Button is Hidden​

const { can } = useActionPermissions();

// Add logging
const hasPermission = can('personnel', 'create');
console.log('Can create personnel:', hasPermission);

Common Issues​

IssueCauseSolution
Button never showsWrong resource nameCheck CTMS Resource in Frappe
Button shows for wrong roleMissing permission recordAdd CTMS Permission in Frappe
Always using fallbackAPI errorCheck network tab, verify API endpoint

TypeScript Reference​

Type Definitions​

// Accepts any string, with known values for autocomplete
export type Resource = KnownResource | (string & {});
export type Action = KnownAction | (string & {});

// Known values (for autocomplete)
export type KnownResource =
| 'study' | 'subject' | 'crf' | 'personnel' | ...;

export type KnownAction =
| 'create' | 'read' | 'update' | 'delete' | ...;

Hook Return Type​

interface UseActionPermissionsReturn {
can: (resource: Resource | string, action: Action | string) => boolean;
canCreate: (resource: Resource) => boolean;
canUpdate: (resource: Resource) => boolean;
canDelete: (resource: Resource) => boolean;
canBulkDelete: (resource: Resource) => boolean;
canExport: (resource: Resource) => boolean;
canImport: (resource: Resource) => boolean;
isReadOnly: boolean;
canWrite: boolean;
allowedActions: (resource: Resource) => Action[];
}