Skip to main content

Entity Selector (Fund/Nonprofit Dropdown)

Entity Selector (Fund/Nonprofit Dropdown)

Component File: src/components/shared/Header.tsx Context: src/contexts/AppContext.tsx Database Functions: src/lib/db.ts Access Level: All authenticated users (behavior varies by role)

Overview

The Entity Selector is the central dropdown in the application header that allows users to switch between different nonprofit organizations (entities/funds). This component is critical for multi-tenant functionality, enabling parent org admins to manage multiple nonprofits from a single interface while restricting fund users to their assigned organization.

UI Features

Visual States

Loading State

┌──────────────────────────────────────┐
│ Loading...                           │
└──────────────────────────────────────┘

Displayed while organizations are being fetched from Supabase on app initialization. No spinner or duck — muted bordered pill with header.loading copy, role="status", and aria-busy="true" (Header.tsx).

Parent Org View (Dropdown)

┌──────────────────────────────────────┐
│ ▼ Awakenings                         │
├──────────────────────────────────────┤
│   All Nonprofits                     │
│   General Fund                       │
│   Awakenings                    ✓    │
│   Bloom Strong                       │
│   Bonfire                            │
│   ...                                │
└──────────────────────────────────────┘

Parent org admins can select any organization or "All Nonprofits" to view aggregated data.

Fund User View (Static)

┌──────────────────────────────────────┐
│ Awakenings                           │
└──────────────────────────────────────┘

Non-parent_org users see a static display locked to their assigned organization.

Responsive Widths

  • Mobile: 180px
  • Tablet: 240px
  • Desktop: 280px

Data Flow

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                        Supabase                                  │
│  organizations table                                             │
│  - id (uuid), name, slug, type, status, visible                 │
│  - created_at, updated_at                                       │
└─────────────────────────────────────────────────────────────────┘
                              ↓
                    fetchOrganizations()
                    (visible=true, status=active)
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│                      AppContext                                  │
│  - organizations: Organization[]                                │
│  - entities: Entity[] (mapped for dropdown)                     │
│  - selectedEntity: EntityId                                     │
│  - setSelectedEntity: (id: EntityId) => void                    │
│  - entitiesLoading: boolean                                     │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│                       Header.tsx                                 │
│  - Renders <Select> for parent_org                          │
│  - Renders static <div> for other roles                         │
│  - Shows loading label (no animation) while entitiesLoading   │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│                    All Components                                │
│  - Use selectedEntity from useApp()                             │
│  - Filter data queries by selectedEntity                        │
│  - Pass entityId to create/update operations                    │
└─────────────────────────────────────────────────────────────────┘

Database Query

// src/lib/db.ts - fetchOrganizations()
const { data, error } = await supabase
  .from('organizations')
  .select('id, name, slug, type, status, visible')
  .eq('status', 'active')
  .eq('visible', true)
  .order('name');

Entity Mapping

// src/contexts/AppContext.tsx
const dynamicEntities = useMemo((): Entity[] => {
  const allOption: Entity = { id: 'all', name: 'All Nonprofits', type: 'all' };
  const mapped = organizations.map((org): Entity => ({
    id: org.slug as EntityId,  // slug is used as EntityId
    name: org.name,
    type: org.type,
  }));
  return [allOption, ...mapped];
}, [organizations]);

Role-Based Access Control

Entity Switching Permissions

| Role | Can Switch Entities | Behavior |
|------|---------------------|----------|
| `parent_org` | ✅ Yes | Full dropdown, can select any org or "All" |
| `fund_user` | ❌ No | Static display, locked to assigned org |
| `donor` | ❌ No | Static display, locked to assigned org |
| `volunteer` | ❌ No | Static display, locked to assigned org |

Implementation

// Header.tsx
const canSwitchEntities = userRole === 'parent_org';

// AppContext.tsx - Enforced setter
const setSelectedEntity = useCallback((entityId: EntityId) => {
  if (canSwitchEntities) {
    setSelectedEntityInternal(entityId);
  } else {
    console.warn('Entity switching not allowed for this user role');
  }
}, [canSwitchEntities]);

visibleEntities Filter (Anti-Regression)

> 🚨 DANGER — DO NOT MODIFY WITHOUT REVIEW > > The visibleEntities filter in Header.tsx has caused two production regressions (Apr 2026). Any change to this logic MUST be reviewed against the four required checks below. If all four checks are not preserved, parent org admins will lose visibility of child funds and file support tickets.

File: src/components/shared/Header.tsx (lines ~128-157)

The visibleEntities filter determines which organizations appear in the entity selector dropdown for parent_org admins. This logic is critical for multi-tenant isolation and has caused production regressions when modified incorrectly.

Defense-in-Depth Requirements

The filter MUST satisfy all of the following conditions for a parent_org admin:

1. Direct membership match: Include any entity whose UUID is in user.organizationIds 2. JWT org_tree_ids match: Include any entity whose UUID is in user.appMetadata.org_tree_ids (if populated) 3. Child-of-parent fallback: Include any entity whose parent_organization_id matches an org in user.organizationIds 4. Child-of-tree fallback: Include any entity whose parent_organization_id matches an org in org_tree_ids

Why All Four Checks Are Required

| Check | Rationale |
|-------|-----------|
| Direct membership | Parent org admin is directly assigned to parent org |
| JWT org_tree_ids | Optimized JWT claim containing full org hierarchy (may be stale) |
| Child-of-parent fallback | **CRITICAL:** Catches child funds when JWT lacks `org_tree_ids` or is cached from before the claim existed |
| Child-of-tree fallback | Belt-and-suspenders for edge cases where tree has partial data |

Regression History

| Date | Symptom | Root Cause | Feedback ID |
|------|---------|------------|-------------|
| Apr 18, 2026 | Dropdown collapsed to parent org only for enterprise admins | Removed `parent_organization_id` fallback; relied solely on JWT `org_tree_ids` which may be stale/missing | `ac6a3f82-e657-4781-9936-a793fd155a0c` |
| Apr 2026 (earlier) | Same symptom | Same root cause — introduced during JWT claim migration | — |

Pattern: Every regression has involved removing or weakening the parent_organization_id fallback checks. The JWT org_tree_ids claim is an optimization, not a replacement — cached sessions may predate the claim.

Correct Implementation

// Header.tsx — visibleEntities filter for parent_org admins
// DO NOT remove the parent_organization_id fallback checks!

const parentOrgTreeIds = userRole === 'parent_org'
  ? (user?.appMetadata?.org_tree_ids && user.appMetadata.org_tree_ids.length > 0
      ? user.appMetadata.org_tree_ids
      : (user?.organizationIds || []))
  : [];
const parentOrgTreeIdSet = userRole === 'parent_org' ? new Set(parentOrgTreeIds) : null;
const parentOrgUserOrgIdSet = userRole === 'parent_org'
  ? new Set(user?.organizationIds || [])
  : null;

const visibleEntities = isMultiFundUser && allowedEntitySlugs
  ? entities.filter(e => allowedEntitySlugs.includes(e.id as any))
  : (userRole === 'parent_org' && (parentOrgTreeIds.length > 0 || (parentOrgUserOrgIdSet?.size ?? 0) > 0))
    ? entities.filter(e => {
        const orgId = getActualOrgId(e.id as EntityId);
        if (!orgId) return false;
        // All four checks — do not remove any:
        if (parentOrgTreeIdSet?.has(orgId)) return true;
        if (parentOrgUserOrgIdSet?.has(orgId)) return true;
        if (e.parent_organization_id && parentOrgUserOrgIdSet?.has(e.parent_organization_id)) return true;
        if (e.parent_organization_id && parentOrgTreeIdSet?.has(e.parent_organization_id)) return true;
        return false;
      })
    : entities;

Code Review Checklist (MANDATORY)

When reviewing any change to Header.tsx that touches visibleEntities, parentOrgTreeIdSet, parentOrgUserOrgIdSet, or the filter logic, all boxes must be checked before approval:

  • [ ] Filter still includes parentOrgTreeIdSet?.has(orgId) check (JWT tree match)
  • [ ] Filter still includes parentOrgUserOrgIdSet?.has(orgId) check (direct membership match)
  • [ ] Filter still includes e.parent_organization_id + parentOrgUserOrgIdSet check (CRITICAL fallback)
  • [ ] Filter still includes e.parent_organization_id + parentOrgTreeIdSet check (belt-and-suspenders)
  • [ ] Tested with a user whose JWT has no org_tree_ids claim (simulates stale/cached session)
  • [ ] Tested with a parent org admin who has 2+ child funds — all appear in dropdown

If any check fails, the PR must not merge. This filter has regressed twice; do not assume "simplification" is safe.

Integration with Fund Management

The Entity Selector is directly linked to the Fund Management admin tool:

Visibility Control

When an organization's visibility is toggled in Fund Management: 1. toggleOrganizationVisibility() updates visible and status columns 2. On next app load, fetchOrganizations() excludes hidden orgs 3. Entity Selector dropdown reflects the change

Protected Organizations

  • Parent Org (type: parent_org) - Cannot be hidden
  • General Fund - Cannot be hidden
  • Both always appear in the dropdown
  • Organization names are loaded dynamically from the database (no hardcoded names)

Adding New Organizations

When a new nonprofit is added via Fund Management: 1. createOrganization() inserts with visible=true, status='active' 2. New org appears in dropdown on next page load 3. All data queries can filter by the new entity

State Management

AppContext State

interface AppContextType {
  entities: Entity[];           // Mapped organizations for dropdown
  entitiesLoading: boolean;     // Loading state
  selectedEntity: EntityId;     // Current selection (slug)
  setSelectedEntity: (id: EntityId) => void;
}

EntityId Type

// Dynamic string type for multi-tenant support
// Organization slugs are loaded from the database at runtime
type EntityId = string;

// Special values:
// - 'all' - Aggregate view (pseudo-entity for parent orgs)
// - Any organization slug from the database

Entity Interface

interface Entity {
  id: EntityId;     // Organization slug
  name: string;     // Display name
  type: string;     // 'nonprofit', 'parent_org', 'all'
}

Usage in Components

Basic Usage

import { useApp } from '../contexts/AppContext';

const MyComponent: React.FC = () => {
  const { selectedEntity } = useApp();
  
  // Use selectedEntity to filter data
  const { data } = useQuery({
    queryKey: ['donations', selectedEntity],
    queryFn: () => fetchDonations(selectedEntity),
  });
  
  return <div>...</div>;
};

Creating Records with Entity

const handleCreate = async (data: FormData) => {
  // Use dynamic parent org lookup instead of hardcoded slug
  const parentOrgEntity = entities.find(e => e.type === 'parent_org');
  await createDonation({
    ...data,
    entity_id: selectedEntity === 'all' 
      ? (parentOrgEntity?.id || selectedEntity) 
      : selectedEntity,
  });
};

Components Using selectedEntity

The following components filter data by selectedEntity:

  • DonationsManager, DonorsCRM, ExpensesManager
  • ChartOfAccountsManager, CheckDepositManager
  • All CRM components (Contacts, Prospects, Volunteers)
  • All Reports (Balance Sheet, Income Statement, etc.)
  • Reconciliation, Journal Entries, General Ledger (Transaction Ledger follows the selected header fund; parent org selection shows all funds unless the user further narrows with General Ledger’s optional Filter by fund)
  • And 40+ more components

Error Handling

Network Errors

If fetchOrganizations() fails:

  • Error logged to console
  • entitiesLoading set to false
  • Dropdown shows empty or cached data

Invalid Entity Selection

If selected entity becomes invalid (e.g., org hidden):

  • AppContext falls back to the parent org entity (dynamically determined)
  • User sees parent org view

Loading States

| State | Display |
|-------|---------|
| `entitiesLoading=true` | Muted pill with `header.loading` text only (no spinner or duck) |
| `entitiesLoading=false` | Dropdown or static display |
| Empty entities array | Dropdown with only "All Nonprofits" |

Database Schema

organizations Table

| Column | Type | Description |
|--------|------|-------------|
| `id` | uuid | Primary key |
| `name` | varchar | Display name |
| `slug` | varchar (unique) | Used as EntityId |
| `type` | varchar | 'nonprofit', 'parent_org', 'internal' |
| `status` | varchar | 'active' or 'inactive' |
| `visible` | boolean | Controls dropdown visibility |
| `created_at` | timestamp | When created |
| `updated_at` | timestamp | Last modified |

Visibility Rules

  • visible=true + status='active' → Appears in dropdown
  • visible=false OR status='inactive' → Hidden from dropdown
  • type='internal' → Never shown (system records)

Best Practices for Entity Filtering

Last Updated: January 15, 2026

UUID vs Slug Comparison (CRITICAL)

When filtering data by organization in frontend components, always use UUID comparison rather than slug comparison to avoid timing issues with entity mapping initialization.

❌ Problematic Pattern (Slug-Based)

// This can fail if getEntityId() returns null before mapping is initialized
const filteredExpenses = expenses.filter(exp => {
  return selectedEntity === 'all' || exp.entityId === selectedEntity;
});

✅ Recommended Pattern (UUID-Based)

import { getActualOrgId } from '../../../lib/entityMapping';

// Use organizationId (UUID) for reliable matching
const selectedOrgId = selectedEntity === 'all' ? null : getActualOrgId(selectedEntity as EntityId);
const filteredExpenses = expenses.filter(exp => {
  return selectedEntity === 'all' || exp.organizationId === selectedOrgId;
});

Why This Matters

1. Entity mapping timing: getEntityId(uuid) can return null if called before initializeEntityMapping() completes 2. Silent failures: When entityId is null, the filter exp.entityId === selectedEntity fails silently, showing no data 3. UUIDs are stable: Organization UUIDs from the database are always available and don't depend on mapping initialization

Data Model Pattern

When defining interfaces that include organization data, include both entityId (slug) and organizationId (UUID):

interface Expense {
  entityId: string;        // Slug for display purposes
  organizationId: string;  // UUID for filtering (reliable)
  // ... other fields
}

Components Updated with UUID Pattern

  • ExpensesManager.tsx (Jan 15, 2026)

Implementation Status

Multi-Tenant Updates (Dec 30, 2025)

  • Removed all hardcoded organization names (InFocus Ministries, infocus, etc.)
  • EntityId type changed from union of literals to dynamic string
  • Legacy static entities array deprecated and emptied
  • All components now use dynamic entity type checks (entity.type === 'parent_org')
  • Organization names loaded from database at runtime for true multi-tenant support
| Feature | Status | Notes |
|---------|--------|-------|
| Dropdown for parent_org | ✅ Complete | Full functionality |
| Static display for other roles | ✅ Complete | Locked to assigned org |
| Loading state | ✅ Complete | Spinner while loading |
| Supabase integration | ✅ Complete | Real-time from organizations table |
| Role-based restrictions | ✅ Complete | Enforced in AppContext |
| Link to Fund Management | ✅ Complete | Visibility changes reflected |
| "All Nonprofits" option | ✅ Complete | Aggregate view for parent_org |
| General Fund priority | ✅ Complete | Moved to top of list |

Related Documentation


Synced from IFMmvp-Frontend documentation: pages/components/20-ENTITY-SELECTOR.md

Ready to Get Started?

See how Alignmint can simplify your nonprofit's operations. Schedule a free demo with our team and we'll walk you through everything.

Questions? Email us at steven@getalignmint.org

Ready to get started?Start Plus Trial