admin-kit

Navigation & panels#

There are two panels, and the split is by kind of work, not kind of account.

PanelForChromeRoute
App panel (launcher)Daily work, for every accountTopbar, tile grid, quick-access dockpanels.app.home, default /dashboard
Admin panelConfiguration workSidebar, topbarpanels.admin.basePath, default /admin

The reference implementation this kit came from flipped the entire chrome on an isAdmin flag. Administrators therefore never saw the launcher, everyone else never saw a sidebar, and "the admin layout" was just the app with different navigation. Splitting by work instead means an administrator uses the same launcher as everyone else and steps into the admin panel when they are administering.

The app panel#

// app/dashboard/page.tsx
import { AppShell, AppLauncher } from '@cortejojicoy/admin-kit/ui'

export default function Page() {
  return (
    <AppShell>
      <AppLauncher />
    </AppShell>
  )
}

<AppShell> takes topbar, search, actions and footer slots; everything it needs — config, router, access — comes from context. <AppLauncher> takes header, primaryAction and empty.

panels: {
  app: {
    home: '/dashboard',
    title: 'Axiomkit',
    search: true,                    // centred search slot in the topbar
    greeting: true,                  // greeting block above the tiles
    dockOrder: ['INBOX', 'TILL'],    // pinned dock modules, in this order
  },
}

Tiles and the dock#

Both come from the same module descriptors and the same access engine, so what a user sees is exactly what their permissions allow — nothing needs a second opinion.

placementWhere it appearsWhat it is for
tileA card on the launcher homeModules people run their shift from — the thing they open and stay in
dockA cell in the quick-access barStations people step into and back out of: check a reading, take a payment
navReachable, not advertised on the home viewSecondary destinations
hiddenNowhereRegistered for access checks only

The tile/dock distinction is the one worth getting right. Given cards of equal weight, stations dilute the grid into a set of equally-likely choices — which is precisely the failure this split exists to avoid.

Emphasis#

{ code: 'USERS', title: 'People', href: '/admin/users', placement: 'tile', emphasis: 'primary' }

primary takes the wide cell and critical the highlighted one beside it. When the user holds neither, the shell falls back to an even grid — so the layout degrades by permission rather than leaving a hole where a tile was.

The admin panel#

// app/admin/layout.tsx
import { AdminShell } from '@cortejojicoy/admin-kit/ui'

export default async function AdminLayout({ children }) {
  // …requireAdmin() here — see Access control
  return <AdminShell>{children}</AdminShell>
}

A permanent rail on wide screens, a slide-in drawer with an overlay below the breakpoint, and a collapsed state that persists. Mounting it is <AdminShell>{children}</AdminShell> and nothing else — 0.1.x required config, Link and currentPath to be threaded in by hand.

Replace the sidebar entirely with sidebar={…}, or fill the topbar slots with topbar={{ title, actions, children }}.

The admin topbar deliberately has no search slot: search in this kit looks up records within a resource, which is the table's job, not the chrome's.

Sections#

panels: {
  admin: {
    basePath: '/admin',
    title: 'Axiomkit admin',
    backTo: '/dashboard',
    roles: ['admin'],
    sections: [
      {
        id: 'people',
        label: 'People',
        order: 10,
        items: [
          { id: 'users', label: 'Users', href: '/admin/users', iconKey: 'users',
            permissions: ['users:list'], requiredLevel: 'view' },
          { label: 'Roles', href: '/admin/roles', roles: ['admin'], badge: 'new' },
        ],
      },
    ],
  },
}

Sidebar sections are static by design: the panel is admin-gated as a whole, so there is no per-item entitlement story to tell inside it.

config.navigation.sections holds the same shape for the classic single-shell layout.

interface NavItem {
  id?: string                  // stable id; used to merge plugin items into config sections
  label: string
  href?: string
  iconKey?: string             // a key into the icon registry, not a component
  badge?: string | number
  external?: boolean
  hidden?: boolean             // hard hide, for feature flags
  roles?: string[]             // visible to holders of any of these roles
  permissions?: string[]       // visible to holders of any of these codes
  accessCodes?: string[]       // alternative codes that also grant this item
  requiredLevel?: AccessLevel  // minimum level over permissions/accessCodes; default 'view'
  children?: NavItem[]         // nested items render as a collapsible group
  order?: number               // sort weight, ascending; default 0
}

0.1.x typed icon as a ReactNode and visible as a predicate, which made any config carrying either one impossible to pass from a server component into a client one. That blocked the entire pattern this kit is built around: resolve navigation on the server, where the user's permissions already are, and hand the finished tree to a client shell.

So icons are string keys and visibility is declarative. Anything genuinely dynamic belongs in a component, not in config.

Building and filtering the tree#

import { buildNav, filterNav, modulesToSections } from '@cortejojicoy/admin-kit'
import { createAccessEngine } from '@cortejojicoy/admin-kit'

const nav = buildNav(config)                       // config sections + plugin sections, merged
const visible = filterNav(nav, createAccessEngine(snapshot, config.access))

The same engine filters nav, decides which tiles render, answers <Can> and backs the server guards — so those four can never disagree.

Filtering is not a gate. A hidden link is still a reachable URL. See Access control.

Modules#

import { buildModules, tileModules, dockModules } from '@cortejojicoy/admin-kit'

const modules = buildModules(catalog, config.modules, { flavor: config.app.flavor })
tileModules(modules)
dockModules(modules, config.panels?.app?.dockOrder)

A catalog entry with no descriptor does not render, and a descriptor with no catalog entry is dropped. Enabling a module server-side therefore makes it appear with no frontend deploy, while presentation stays where presentation belongs.

Icons#

Icons are resolved through a registry, so a string key in config becomes a component at render time.

// lib/icons.tsx
'use client'
import type { IconRegistry } from '@cortejojicoy/admin-kit/client'
import { Building } from 'lucide-react'

export const icons: IconRegistry = { building: Building }
<AdminProvider config={adminConfig} icons={icons}>

Built-in keys: dashboard, users, user, settings, shield, key, list, table, plus, edit, trash, search, bell, home, chevronLeft, chevronRight, chevronDown, menu, logout, grid, activity, alert, check, close, database, document, and fallback. An unknown key renders fallback rather than throwing — a typo in config should not blank a page.

Render one directly with <Icon name="users" />.

Router adapters#

The shells get routing through context, so the same components work on both routers:

import { appRouterAdapter } from '@cortejojicoy/admin-kit/client'   // App Router
import { pagesRouterAdapter } from '@cortejojicoy/admin-kit/client' // Pages Router

<AdminProvider config={adminConfig} router={appRouterAdapter}>

config.router ('app' or 'pages') tells the rest of the kit which flavour to assume — the CLI scaffolds for it, and the docs generator names it.

Documenting your navigation#

npx admin-kit docs writes a navigation.md listing your tiles, dock and sidebar, each annotated with what it requires to be visible.

Edit this page on GitHub