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.
| Resource | Description |
|---|---|
study | Study records |
subject | Subject/participant records |
personnel | Study personnel assignments |
crf | CRF form designs |
crf_entry | Subject CRF data entries |
demographics | Subject demographics |
vitals | Vital signs |
adverse_event | Adverse events |
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:
| Action | Use Case |
|---|---|
create | Add/New buttons |
read | View/List access |
update | Edit buttons |
delete | Delete buttons |
bulk_delete | Bulk delete buttons |
export | Export buttons |
import | Import 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
| Prop | Type | Default | Description |
|---|---|---|---|
resource | string | required | The resource to check (e.g., "study", "personnel") |
action | string | required | The action to check (e.g., "create", "update") |
fallback | ReactNode | null | Content to show when no permission |
showTooltip | boolean | false | Show disabled button with tooltip |
tooltipMessage | string | auto | Custom tooltip message |
disabled | boolean | false | Additional 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
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 Case | Example | Why It's OK |
|---|---|---|
| Data Filtering | Study Coordinators only see subjects from assigned studies | Business logic, not UI control |
| Feature Toggles | Only Admins see AI Chat widget | Feature visibility, not action permissions |
| Dashboard Routing | Different home pages per role | Navigation, 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
| Issue | Cause | Solution |
|---|---|---|
| Button never shows | Wrong resource name | Check CTMS Resource in Frappe |
| Button shows for wrong role | Missing permission record | Add CTMS Permission in Frappe |
| Always using fallback | API error | Check 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[];
}