Skip to content

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: boolean

Best Practices

  1. Fail closed — Default to no permission if check fails
  2. Check on server — Never trust client-only permission checks
  3. Use specific permissions — Prefer EDIT_COSTUMES over EDIT_ALL
  4. Document new permissions — Update this guide when adding permissions


Last updated: January 2026