Permission System Usage
How to add and check permissions in On Book Pro.
Overview
On Book Pro uses a Role-Based Access Control (RBAC) system with:
- Roles — Named collections of permissions (e.g., Stage Manager, Cast Member)
- Permissions — Individual capabilities (e.g., EDIT_SCHEDULE, VIEW_BUDGET)
- Hierarchy — Higher roles inherit lower role permissions
Permission Architecture
┌─────────────────────────────────────────┐
│ OWNER │
│ (All permissions) │
└─────────────────────┬───────────────────┘
│
┌─────────────────────▼───────────────────┐
│ STAGE_MANAGER │
│ (Schedule, Notes, Personnel, Files) │
└─────────────────────┬───────────────────┘
│
┌─────────────────────▼───────────────────┐
│ TEAM_MEMBER │
│ (View most, Edit own areas) │
└─────────────────────┬───────────────────┘
│
┌─────────────────────▼───────────────────┐
│ CAST_MEMBER │
│ (View schedule, Edit own profile) │
└─────────────────────────────────────────┘Adding a New Permission
Step 1: Define the Permission
typescript
// src/features/auth/types.ts
export enum Permission {
// Existing permissions...
VIEW_ALL = 'VIEW_ALL',
EDIT_SCHEDULE = 'EDIT_SCHEDULE',
// Add your new permission
EDIT_MY_FEATURE = 'EDIT_MY_FEATURE',
}Step 2: Assign to Default Roles
typescript
// src/features/admin/default-roles.ts
export const DEFAULT_ROLES: Role[] = [
{
id: 'stage-manager',
name: 'Stage Manager',
permissions: [
Permission.VIEW_ALL,
Permission.EDIT_SCHEDULE,
Permission.EDIT_MY_FEATURE, // Add to appropriate roles
],
},
{
id: 'cast-member',
name: 'Cast Member',
permissions: [
Permission.VIEW_ALL,
// Not included for cast members
],
},
];Step 3: Update Security Rules
javascript
// firestore.rules
function hasPermission(permission) {
return request.auth != null
&& get(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid))
.data.role.permissions.hasAny([permission]);
}
match /projects/{projectId}/myFeature/{docId} {
allow read: if hasPermission('VIEW_ALL');
allow write: if hasPermission('EDIT_MY_FEATURE');
}Checking Permissions in Code
Hook: useHasPermission
tsx
import { useHasPermission } from '@/features/auth/hooks';
import { Permission } from '@/features/auth/types';
export const MyFeatureTab = () => {
const canEdit = useHasPermission(Permission.EDIT_MY_FEATURE);
return (
<div>
{canEdit && (
<Button leftIcon="add" onClick={handleAdd}>
Add Item
</Button>
)}
<ItemList readOnly={!canEdit} />
</div>
);
};Hook Implementation
typescript
// src/features/auth/hooks/useHasPermission.ts
import { useAppStore } from '@/store/useAppStore';
import { Permission } from '../types';
export const useHasPermission = (permission: Permission): boolean => {
const member = useAppStore((s) => s.cloud.member);
if (!member) return false;
// Owner has all permissions
if (member.role.name === 'Owner') return true;
return member.role.permissions.includes(permission);
};Multiple Permissions
tsx
// Check if user has ANY of the permissions
const canManage = useHasAnyPermission([
Permission.EDIT_MY_FEATURE,
Permission.MANAGE_PROJECT,
]);
// Check if user has ALL of the permissions
const canAdminister = useHasAllPermissions([
Permission.EDIT_MY_FEATURE,
Permission.DELETE_MY_FEATURE,
]);Conditional Rendering Patterns
Permission Gate Component
tsx
import { PermissionGate } from '@/features/auth/components';
export const MyFeatureTab = () => (
<div>
<h1>My Feature</h1>
<PermissionGate permission={Permission.EDIT_MY_FEATURE}>
<EditControls />
</PermissionGate>
<PermissionGate
permission={Permission.EDIT_MY_FEATURE}
fallback={<ReadOnlyView />}
>
<EditableView />
</PermissionGate>
</div>
);Implementation
tsx
// src/features/auth/components/PermissionGate.tsx
interface PermissionGateProps {
permission: Permission;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export const PermissionGate = ({
permission,
children,
fallback = null
}: PermissionGateProps) => {
const hasPermission = useHasPermission(permission);
return hasPermission ? <>{children}</> : <>{fallback}</>;
};Server-Side Validation
Cloud Functions
typescript
import { onCall, HttpsError } from 'firebase-functions/v2/https';
export const protectedAction = onCall(async (request) => {
const userId = request.auth?.uid;
if (!userId) {
throw new HttpsError('unauthenticated', 'Must be logged in');
}
// Get member's role
const memberDoc = await db
.collection(`projects/${request.data.projectId}/members`)
.doc(userId)
.get();
if (!memberDoc.exists) {
throw new HttpsError('permission-denied', 'Not a project member');
}
const member = memberDoc.data();
if (!member.role.permissions.includes(Permission.EDIT_MY_FEATURE)) {
throw new HttpsError('permission-denied', 'Insufficient permissions');
}
// Proceed with action...
});Testing Permissions
typescript
describe('Permission checks', () => {
it('allows stage manager to edit', () => {
useAppStore.setState({
cloud: {
member: {
role: {
permissions: [Permission.EDIT_MY_FEATURE],
},
},
},
});
render(<MyFeatureTab />);
expect(screen.getByRole('button', { name: /add/i })).toBeInTheDocument();
});
it('hides edit controls for cast member', () => {
useAppStore.setState({
cloud: {
member: {
role: {
permissions: [Permission.VIEW_ALL],
},
},
},
});
render(<MyFeatureTab />);
expect(screen.queryByRole('button', { name: /add/i })).not.toBeInTheDocument();
});
});Complete Permission Reference
All available permissions in the system:
typescript
enum Permission {
// Content Editing
EDIT_RUNSHEET = 'edit_runsheet',
EDIT_PROPS = 'edit_props',
EDIT_SETS = 'edit_sets',
EDIT_BLOCKING = 'edit_blocking',
EDIT_CAST = 'edit_cast',
EDIT_SCHEDULER = 'edit_scheduler',
EDIT_NOTES = 'edit_notes',
EDIT_SOUND = 'edit_sound',
EDIT_FILES = 'edit_files',
// Production Elements
MANAGE_COSTUMES = 'manage_costumes',
VIEW_BUDGET = 'view_budget',
// Administration
MANAGE_MEMBERS = 'manage_members',
MANAGE_ROLES = 'manage_roles',
VIEW_ADMIN = 'view_admin',
MANAGE_PROJECT = 'manage_project',
DELETE_PROJECT = 'delete_project'
}Firestore Structure
text
projects/{projectId}
- ownerId: string
- ownerEmail: string
/members/{userId}
- userId: string
- email: string
- displayName: string
- roleId: string
- status: 'pending' | 'approved' | 'rejected'
- linkedContactId?: string
/roles/{roleId}
- id: string
- name: string
- permissions: string[]
- isCustom: booleanBest Practices
- Fail closed — Default to no permission if check fails
- Check on server — Never trust client-only permission checks
- Use specific permissions — Prefer
EDIT_COSTUMESoverEDIT_ALL - Document new permissions — Update this guide when adding permissions
Related Documentation
- Authentication & RBAC — Auth architecture
- Firestore Security Rules — Rule implementation
- Personnel Linking API — User-to-personnel linking
Last updated: January 2026