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[];
}