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+parentOrgUserOrgIdSetcheck (CRITICAL fallback) - [ ] Filter still includes
e.parent_organization_id+parentOrgTreeIdSetcheck (belt-and-suspenders) - [ ] Tested with a user whose JWT has no
org_tree_idsclaim (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 databaseEntity 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
entitiesLoadingset 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 dropdownvisible=falseORstatus='inactive'→ Hidden from dropdowntype='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.) EntityIdtype changed from union of literals to dynamicstring- Legacy static
entitiesarray 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
- 01-HEADER.md - Parent component
- ../administration/02-NONPROFIT-MANAGEMENT.md - Admin tool for managing orgs
- 01-DATA-SCHEMA.md - Historical database schema
- 04-USER-ROLES-AND-PERMISSIONS.md - Historical role definitions
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