Sidebar Route Bundles
The application sidebar is composed from one component per section. Each component lives under src/components/routes/**, reads permissionKeys from TeamsContext, and renders a single <NavProjects /> group with the items the current user is allowed to see.
This pattern keeps the sidebar declarative and removes the need for ad-hoc visibility checks inside app-sidebar.tsx.
Files
src/components/routes/
├── accounting-routes.tsx # Turn-Ins, Rates, Transactions (campaign-scoped fallbacks supported)
├── campaign-admin-routes.tsx # Manage Campaign, Petition Formats, Sub-teams, Regions
├── campaign-routes.tsx # My Dashboard, Petitions, Validators, Circulators, Signatures, Households
├── circulator-routes.tsx # Circulator dashboards (by campaign / by turn-in date)
├── common-routes.tsx # Voter Search, Admin Voter Search
├── super-admin-routes.tsx # Credentials, All Teams, Roles, Responsibilities, Permissions
├── team-management-routes.tsx # Team Dashboard, Campaigns, Members
└── user-settings-route.tsx # Profile (always rendered)
Composition
src/components/common/app-sidebar.tsx decides which bundles to render based on the current layout:
<SidebarContent>
{activeCampaign && <CampaignRoutes />}
{activeCampaign && <CampaignAdminRoutes />}
{layout !== "root" && <CirculatorRoutes />}
{layout !== "root" && <TeamManagementRoutes />}
{layout !== "root" && <AccountingRoutes />}
{layout !== "root" && <SuperAdminRoutes />}
{layout !== "root" && <CommonRoutes />}
<UserSettingsRoute />
</SidebarContent>
layout comes from the parent route layout and is one of "root" | "teams" | "campaign" | "circulator".
Bundle pattern
Every bundle follows the same shape:
const SomeRoutes = () => {
const params = useParams<{ slug: string; primaryTeam: string }>();
const { permissionKeys, superAdminTeamId } = useTeams();
const items = [
...(permissionKeys?.includes("some-perm-key") ||
params.primaryTeam === superAdminTeamId
? [{
name: "Some Page",
url: `/${params.primaryTeam}/some-page`,
icon: SomeIcon,
}]
: []),
// ... more items
];
return items.length > 0 ? (
<NavProjects projects={items} label="Section Title" />
) : null;
};
Notes:
permissionKeysis provided byTeamsContext, hydrated fromgetRoutePermissions({ teamId })in the team server layout.- The super-admin team (
SUPER_ADMIN_TEAM_ID, exposed viasuperAdminTeamId) bypasses key checks so super-admins always see every section. - If no items survive the gating, the bundle returns
nullso no empty section appears. accounting-routes.tsxswitches between team-scoped (/${primaryTeam}/all-turn-ins) and campaign-scoped (/${primaryTeam}/campaign/${slug}/turn-in) URLs based on whetherparams.slugis defined — letting the same bundle be reused inside both layouts.
Adding a new sidebar item
- Confirm or add the underlying permission key in
App/src/lib/data/permissions/standard-permissions.ts. - Confirm the page itself calls
requireAccess({ teamId, key: "<your-key>" }). - Add the item to the appropriate bundle, using the OR-with-super-admin pattern above.
- (Optional) If the item must appear in a brand-new section, create a new file under
src/components/routes/and add it toapp-sidebar.tsx.
Why bundles instead of one big config?
- Keeps each section's gating + icon set co-located with its routes.
- Makes the diff for "permission key X now also gates page Y" tiny.
- Avoids passing huge config arrays through React tree props.
- Matches how
requireAccessenforces gates server-side — the sidebar is just a mirror of the same key set.