OfficeGest
Module System

Navigation

How modules declare sidebar navigation, how the registry merges and orders it, and how privileges gate what each user sees.

Introduction

Every module declares its own slice of the application sidebar through the sidebar field on its ModuleDefinition. The registry collects those slices from all licensed modules, merges them into one tree, and orders the top level. The sidebar renderer then filters the tree by the current user's privileges and orders every level before painting it.

This split is deliberate. A module owns what it contributes to the navigation — its labels, icons, destinations, and the privileges that guard them. The registry owns assembly — concatenating every module's contribution into a single array. The renderer owns presentation — privilege filtering, recursive ordering, active-trail highlighting, and search. No module knows about any other module's navigation, and none of them reach into the rendering layer.

Navigation sits alongside routes and slots on the same ModuleDefinition. See Anatomy for where sidebar fits in the full module definition shape.


The SidebarItem type

A sidebar node is a discriminated shape defined in @/core/types: a set of common fields intersected with either a destination or a list of children.

export type SidebarItem = {
  label: string;
  icon?: ComponentType;
  privilege?: string;
  order?: number;
} & ({
  to: To;
  link?: boolean;
} | {
  children: SidebarItem[];
});

The common fields apply to every node, leaf or group:

FieldTypePurpose
labelstringAn i18n key, not display text. The renderer runs it through t(item.label) before painting.
iconComponentType (optional)A React component rendered as the node's icon. Modules pass a lucide-react icon component here.
privilegestring (optional)A privilege string that gates the node's visibility. Omit for an always-visible node.
ordernumber (optional)Sort key among siblings. Defaults to 50 when omitted.

A node then takes exactly one of two branches:

BranchFieldsMeaning
Leaf linkto: To, link?: booleanA navigable destination. to is a react-router To (a path string or a location object).
Groupchildren: SidebarItem[]A collapsible group whose entries are themselves SidebarItems (recursively).

A node is either a destination or a group, never both. This is the intended authoring contract: give a node a to to make it a clickable leaf, or give it children to make it a collapsible group. Do not put both on the same node.

link?: boolean is part of the leaf shape in the type, but the current sidebar renderer does not read it — every leaf renders as a react-router <Link to={item.to}>. Treat it as reserved; do not depend on any visual behavior from it today.


How the registry assembles navigation

createRegistry in @/core/registry builds the merged tree. Its job for navigation is narrow and worth stating precisely, because the rest of the behavior lives in the renderer.

Filter by license

createRegistry receives the discovered modules and the licensedModules id list. It imports only modules whose id is in the license set; an unlicensed module's sidebar never enters the merge. This is a module-level filter by id — it is not privilege filtering.

Concatenate every module's sidebar

For each licensed module that defines sidebar, the registry spreads its items into one flat accumulator:

if (definition.sidebar) {
  allSidebar.push(...definition.sidebar);
}

Children are carried along untouched inside each top-level item — the registry never descends into them.

Sort the top level by order

The accumulated array is sorted once, at the top level only, with 50 as the default when order is omitted:

const sidebar = allSidebar.sort((a, b) => (a.order ?? 50) - (b.order ?? 50));

The result is stored on ModuleRegistry.sidebar.

createRegistry sorts only the top level and never reads privilege. The order values on nested children are authoring metadata that the registry does not apply, and privilege gating does not happen here. Both of those are the renderer's responsibility — see the next section.


How the renderer filters and orders

The consumer of useSidebar() is AppSidebar (@/components/app-sidebar). Its prepareItems helper walks the merged tree recursively and does three things the registry does not:

  • Privilege gating. If a node has a privilege and the current user lacks it, the node is dropped. The check is permissions.includes("all") || permissions.includes(privilege), sourced from the auth store.
  • Empty-group pruning. A group whose children all get filtered out is itself removed, so an empty collapsible never appears.
  • Recursive ordering. Every level is sorted by order ?? 50, so nested children are ordered too — at render time, not in the registry.
function prepareItems(items, hasPermission) {
  return items
    .map((item) => {
      if (item.privilege && !hasPermission(item.privilege)) return null;
      const base = { label: item.label, icon: item.icon, order: item.order ?? 50 };
      if ("children" in item) {
        const children = prepareItems(item.children, hasPermission);
        if (children.length === 0) return null;
        return { ...base, children };
      }
      return { ...base, to: toPath(item.to) };
    })
    .filter((item) => item !== null)
    .sort((a, b) => a.order - b.order);
}

Because ordering of nested children is applied by the renderer (not the registry), it is the module author's responsibility to assign sensible order values within each group. A child without order falls back to 50, exactly as the top level does. See Routing for how privilege strings relate to the authorizationMiddleware that guards the route a leaf points at.


Worked example: the crm module

The crm module declares a single top-level group that nests two levels deep — a group containing leaves and subgroups, and those subgroups containing their own leaves. It shows every field of SidebarItem in use.

import type { ModuleDefinition } from "@/core";
import { routes } from "./routes";
import { ContactRound } from "lucide-react";

export default {
  id: "crm",
  routes,
  sidebar: [
    {
      label: "crm",
      icon: ContactRound,
      order: 20,
      privilege: "crm",
      children: [
        { label: "painelresumo", to: "/crm/dashboard", privilege: "crm:painelresumo", order: 10 },
        { label: "agenda", to: "/crm/agenda", privilege: "crm:agenda", order: 20 },
        {
          label: "pipeline",
          order: 30,
          children: [
            { label: "leads", to: "/crm/leads", privilege: "crm:leads" },
            { label: "oportunidadesnegocios", to: "/crm/opportunities", privilege: "crm:oportunidadesnegocios" },
            { label: "ocupacao", to: "/crm/occupancy", privilege: "crm:ocupacao" },
          ],
        },
        {
          label: "atividade",
          order: 40,
          children: [
            { label: "marcacoes", to: "/crm/appointments", privilege: "crm:marcacoes" },
            { label: "tarefas", to: "/crm/tasks", privilege: "crm:tarefas" },
            { label: "communications", to: "/crm/communications", privilege: "crm:communications" },
            { label: "messages", to: "/messages", privilege: "crm:messages" },
          ],
        },
        { label: "valesoferta", to: "/crm/gift-vouchers", privilege: "crm:valesoferta", order: 50 },
        {
          label: "apoio",
          privilege: "crm:apoio",
          order: 999,
          children: [
            { label: "campanhas", to: "/crm/settings/campaigns", privilege: "crm:apoio:campanhas" },
            { label: "equipas", to: "/crm/settings/teams", privilege: "crm:apoio:equipas" },
            { label: "areasnegocio", to: "/crm/settings/business-areas", privilege: "crm:apoio:areasnegocio" },
            { label: "pipeline", to: "/crm/settings/pipeline", privilege: "crm:apoio:pipeline" },
            { label: "fases", to: "/crm/settings/phases", privilege: "crm:apoio:fases" },
            { label: "categorias", to: "/crm/settings/categories", privilege: "crm:apoio:categorias" },
            { label: "objectivos", to: "/crm/settings/goals", privilege: "crm:apoio:objectivos" },
            { label: "tipotarefa", to: "/crm/settings/task-types", privilege: "crm:apoio:tipotarefa" },
            { label: "abc", to: "/crm/settings/abc", privilege: "crm:apoio:abc" },
            { label: "sms", to: "/crm/settings/sms", privilege: "crm:apoio:sms" },
          ],
        },
      ],
    },
  ],
} satisfies ModuleDefinition;

A few things to read out of this:

  • The root is a group, not a link. crm has children and no to, so it renders as a collapsible with the ContactRound lucide icon. Only the root carries an icon here; nested nodes omit it.
  • order controls sibling position at each level. Inside the crm group, the renderer orders painelresumo (10) → agenda (20) → pipeline (30) → atividade (40) → valesoferta (50) → apoio (999). The 999 on apoio is the idiom for "always last".
  • Nesting goes group → subgroup → leaf. pipeline and atividade are subgroups whose children are leaves with to destinations.
  • Privileges form a hierarchy by convention. crm gates the whole group; crm:agenda gates one leaf; crm:apoio:campanhas gates a leaf two levels deep. If a user lacks crm, the renderer drops the entire group in one step. If they lack only crm:apoio but have other crm:* privileges, the apoio subgroup disappears while the rest of CRM stays — and if every child of a subgroup is filtered out, that subgroup is pruned too.
  • The pipeline children omit order. They fall back to 50 each, so they keep declaration order relative to one another.

The useSidebar() hook

useSidebar() from @/core is the consumer API. It reads the registry from context and returns the merged, top-level-sorted SidebarItem[]:

export function useSidebar() {
  return useRegistry().sidebar;
}

The hook returns the same ModuleRegistry.sidebar array the registry produced — already merged across modules and sorted at the top level. It does not apply privilege filtering or recursive child ordering; a consumer that wants the painted tree (filtered and fully ordered) does that itself, the way AppSidebar does with prepareItems against the auth store's permissions.

import { useSidebar } from "@/core";

function MyNavConsumer() {
  const items = useSidebar();
  return items.map((item) => /* render, filtering by privilege yourself */);
}

If the registry is still loading (or no modules are licensed), useSidebar() returns an empty array — the provider seeds context with an empty registry until createRegistry resolves.