@econneq/rbac-layout-engine
v1.0.1
Published
Headless, fully-configurable RBAC + app-shell layout engine — Sidebar, Header, AuthGuard, RbacGuard, NotAuthorized & SessionExpired pages. Plug a config, get a production app shell.
Downloads
78
Maintainers
Readme
@econneq/rbac-layout-engine
Headless, fully-configurable RBAC + app-shell layout engine for React 18+. Plug a config, get a production-grade app shell — sidebar, header, drawer, route guards, and a 403/session-expired flow that all honour your role & permission graph.
┌─────────────────────────────────────────────────────────┐
│ <RbacLayoutProvider config={...}> │
│ <AppLayout> │
│ <Sidebar> ← modules can contribute items │
│ <Header> ← modules can contribute actions │
│ <main> ← your routes (guarded) │
│ </AppLayout> │
│ </RbacLayoutProvider> │
└─────────────────────────────────────────────────────────┘The engine is truly headless: it never imports your auth library directly.
You pass in a useAuthState() hook (one-liner against @econneq/auth-react,
NextAuth, Clerk, Auth.js, your own Redux slice — anything) and the engine
takes care of access decisions, token-expiry detection, redirects, sidebar
filtering, and 403 pages.
Features
- Headless — zero hard dependencies on any auth library. Bring your own.
- Config-driven — one
defineRbacLayoutConfig({...})call and you're done. - Strict JWT validation —
exp/nbfchecked, configurable refresh threshold, auto-redirect to a session-expired page when the token lapses. - Env-var control — toggle the auth and RBAC subsystems via
NEXT_PUBLIC_RBAC_ENABLED/NEXT_PUBLIC_AUTH_ENABLED(configurable names). Useful for staging environments and feature gates. - Module registry — feature modules can register sidebar items and header actions, statically in config or dynamically via hooks at runtime.
- Three denial strategies per item —
hide(default),disable, orshow(block on click). Configurable globally. - Permission wildcards —
"*"(super-admin),"billing:*"(namespace). - Theme system — CSS custom properties scoped under
[data-rle-root], defaults to a polished dark-blue palette. Light, dark, and auto modes. - Responsive — desktop (sidebar + header), tablet (auto-collapsed), mobile (drawer overlay). All breakpoints configurable.
- Zero UI dependencies — pure inline styles. No CSS-in-JS runtime, no Tailwind requirement, no Radix, no styled-components.
Install
npm install @econneq/rbac-layout-engine
# or
pnpm add @econneq/rbac-layout-engine
# or
yarn add @econneq/rbac-layout-enginePeer dependencies (required):
npm install react react-domOptional peer dependencies — wire to these if you're using the Econneq auth stack:
npm install @econneq/auth-core @econneq/auth-reactThe package works without them — you only need them if you want
useAuthState to be a one-liner over Econneq's auth.
Quick start
// app/layout.tsx (Next.js App Router) — or your top-level component
'use client'
import {
RbacLayoutProvider,
AppLayout,
defineRbacLayoutConfig,
} from '@econneq/rbac-layout-engine'
import { useAuth } from '@econneq/auth-react' // or your own auth hook
import { usePathname } from 'next/navigation'
const config = defineRbacLayoutConfig({
appName: 'Acme Console',
usePathname,
auth: {
enabled: 'env', // respect NEXT_PUBLIC_AUTH_ENABLED, default true
useAuthState: () => {
const a = useAuth()
return {
isAuthenticated: a.isAuthenticated,
loading: a.loading,
user: a.user,
token: a.token,
roles: a.user?.roles ?? [],
permissions: a.user?.permissions ?? [],
}
},
loginUrl: '/auth/login',
sessionExpiredUrl: '/auth/session-expired',
refreshThresholdSec: 30,
},
rbac: {
enabled: 'env', // respect NEXT_PUBLIC_RBAC_ENABLED
superRoles: ['superadmin'],
itemDeniedStrategy: 'hide',
},
theme: {
mode: 'dark', // 'dark' | 'light' | 'auto'
},
layout: {
initialMenu: [
{ id: 'home', label: 'Dashboard', href: '/' },
{ id: 'billing', label: 'Billing', href: '/billing',
access: { permissions: ['billing:read'] } },
{ id: 'admin', label: 'Admin', href: '/admin',
access: { roles: ['admin'] } },
],
},
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<RbacLayoutProvider config={config}>
<AppLayout>{children}</AppLayout>
</RbacLayoutProvider>
</body>
</html>
)
}That's it. You now have:
- A dark-themed app shell with a sidebar, header, and content area.
- An auto-collapsed sidebar on tablet, drawer on mobile.
- Sidebar items filtered by the current user's roles/permissions.
- Automatic redirect to
/auth/login?next=/...for unauthenticated visitors. - Automatic redirect to
/auth/session-expiredwhen the JWT expires. - A 403 page when an RBAC rule denies access.
RBAC rules
Every gate-able thing (route, sidebar item, header action) accepts an
AccessRule:
interface AccessRule {
roles?: string[]
permissions?: string[]
mode?: 'all' | 'any' // default: 'all' (configurable globally)
check?: (state: AuthState) => boolean // custom predicate
}// Need ALL of these
{ roles: ['admin'], permissions: ['users:read'] }
// Need ANY of these
{ permissions: ['billing:read', 'billing:write'], mode: 'any' }
// Wildcards — namespace match
{ permissions: ['reports:*'] } // matches reports:read, reports:write, ...
// Custom logic
{ check: (auth) => auth.user?.tenantId === 'acme' }Super-admin bypass — anyone with a role in superRoles (default
['superadmin']) or a permission in superPermissions (default ['*'])
passes every rule. Use this for a single "god-mode" account during ops.
Guards
Four guards, each composable:
import {
AuthGuard,
RbacGuard,
RouteGuard,
Protected,
} from '@econneq/rbac-layout-engine'
// Whole-page: requires login.
<AuthGuard>
<AccountSettings />
</AuthGuard>
// Whole-page: requires login + rule.
<RouteGuard rule={{ roles: ['admin'] }}>
<AdminPage />
</RouteGuard>
// Just the rule (skip the auth check).
<RbacGuard rule={{ permissions: ['billing:read'] }}>
<BillingTable />
</RbacGuard>
// Inline — render nothing if denied (no 403 page).
<Protected rule={{ permissions: ['users:delete'] }}>
<button onClick={onDelete}>Delete user</button>
</Protected>Programmatic version:
import { useRbac, useAuthGuard } from '@econneq/rbac-layout-engine'
function DeleteButton() {
const { can } = useRbac()
if (!can({ permissions: ['users:delete'] })) return null
return <button>Delete</button>
}
function MyPage() {
const { allowed, loading, reason } = useAuthGuard({ roles: ['admin'] })
if (loading) return <Spinner />
if (!allowed && reason === 'unauthenticated') return <Redirect to="/login" />
if (!allowed) return <Forbidden />
return <AdminUI />
}Sidebar items
Four kinds: link (default), group, divider, section.
{
layout: {
initialMenu: [
// Section header (uppercase label)
{ id: 'sec-main', kind: 'section', label: 'Main' },
// Plain link
{ id: 'home', label: 'Dashboard', href: '/', icon: <HomeIcon/> },
// Group with collapsible children
{ id: 'reports', kind: 'group', label: 'Reports', icon: <ChartIcon/>,
children: [
{ id: 'reports-sales', label: 'Sales', href: '/reports/sales' },
{ id: 'reports-churn', label: 'Churn', href: '/reports/churn',
access: { permissions: ['reports:churn'] } },
] },
// Visual separator
{ id: 'div-1', kind: 'divider' },
// External link
{ id: 'docs', label: 'Docs', href: 'https://docs.acme.io', external: true },
// Item with a badge
{ id: 'inbox', label: 'Inbox', href: '/inbox', badge: 12 },
],
},
}Active-link detection is automatic (compares pathname to href). Override
with match: (pathname, item) => boolean on any link.
Module registration
Feature modules can contribute menu items and header actions without touching the root config:
// Static — declared up-front
defineRbacLayoutConfig({
modules: [
{
id: 'billing-module',
menuItems: [
{ id: 'billing', label: 'Billing', href: '/billing' },
],
headerActions: [
{ id: 'cart', label: 'Cart', icon: <CartIcon/>, href: '/cart', badge: 3 },
],
},
],
})// Dynamic — at runtime, from inside a feature provider
import { useRegisterMenuItems } from '@econneq/rbac-layout-engine'
function BillingProvider({ children }) {
useRegisterMenuItems('billing-module', [
{ id: 'invoices', label: 'Invoices', href: '/billing/invoices' },
])
return children
}Items unmount cleanly when their owning component unmounts.
Theming
Defaults to a polished dark-blue palette. Override per-mode or globally:
defineRbacLayoutConfig({
theme: {
mode: 'dark', // 'dark' | 'light' | 'auto'
tokens: { // global override (wins over per-mode)
colors: {
primary: '#22d3ee',
primaryForeground: '#0b1220',
},
radii: { md: '10px' },
layout: { sidebarWidth: 280 },
},
dark: { // dark-mode-only overrides
colors: { sidebarBackground: '#000' },
},
light: { // light-mode-only overrides
colors: { background: '#fafafa' },
},
// Optional — drive `mode` from your own theme switcher hook
useThemeMode: () => useMyThemeStore((s) => s.mode),
},
})All tokens are emitted as CSS custom properties under [data-rle-root]:
[data-rle-root] {
--rle-color-primary: #3b82f6;
--rle-color-background: #0b1220;
--rle-color-sidebar-background: #0d1424;
--rle-radius-md: 8px;
--rle-sidebar-width: 260px;
/* ... */
}You can override these from your own stylesheet, scoped or globally — no need to go through the config object for ad-hoc tweaks.
Environment-variable control
Both subsystems can be toggled by environment variable:
# .env
NEXT_PUBLIC_AUTH_ENABLED=true
NEXT_PUBLIC_RBAC_ENABLED=trueSet the enabled field to 'env' (the default) to consult the variable.
The engine probes, in order: process.env, import.meta.env, and
globalThis.__ENV__. Default variable names:
- Auth:
NEXT_PUBLIC_AUTH_ENABLED,VITE_AUTH_ENABLED,AUTH_ENABLED - RBAC:
NEXT_PUBLIC_RBAC_ENABLED,VITE_RBAC_ENABLED,RBAC_ENABLED
Override the list per-app via envVarNames:
defineRbacLayoutConfig({
envVarNames: {
auth: ['MY_AUTH_FLAG'],
rbac: ['MY_RBAC_FLAG'],
},
})When the variable is unset, the default is enabled. Use explicit
auth.enabled: false for "public-by-default" apps.
Token expiry
For any state.token that looks like a JWT, the engine decodes the exp
claim and:
- Compares it immediately on every auth-state change.
- Sets a
setTimeoutalarm atexp - refreshThresholdSec. - Redirects to
auth.sessionExpiredUrlwhen the timer fires.
If the token isn't a JWT, supply auth.expiresAt directly as a Unix-ms
timestamp, or provide your own auth.validateToken: (token) => result.
The redirect is loop-safe — already on the session-expired or login URL, it stays put.
Customising the default pages
defineRbacLayoutConfig({
pages: {
notAuthorized: MyForbiddenPage, // 403
sessionExpired: MySessionExpiredPage, // /auth/session-expired
loading: MyLoadingPage, // shown while auth.loading
},
})The built-in pages are themed by CSS variables. Drop them into your own
chrome by reusing NotAuthorizedPage, SessionExpiredPage, LoadingPage
from this package.
Responsive behaviour
| Breakpoint | Default range | Sidebar behaviour |
| --- | --- | --- |
| Mobile | ≤ 767px | Hidden; replaced by drawer overlay (hamburger toggle). Escape & backdrop close. |
| Tablet | 768px – 1023px | Auto-collapses to icon rail unless persisted. |
| Desktop | ≥ 1024px | Full sidebar; user can collapse via the toggle. |
Breakpoints are configurable via theme.tokens.layout.mobileBreakpoint and
tabletBreakpoint.
Integration recipes
Without @econneq/auth-react (e.g. NextAuth)
import { useSession, signIn, signOut } from 'next-auth/react'
const config = defineRbacLayoutConfig({
auth: {
useAuthState: () => {
const { data, status } = useSession()
return {
isAuthenticated: status === 'authenticated',
loading: status === 'loading',
user: data?.user,
token: (data as any)?.accessToken ?? null,
roles: (data?.user as any)?.roles ?? [],
permissions: (data?.user as any)?.permissions ?? [],
}
},
redirect: (url) => signIn(undefined, { callbackUrl: url }),
},
})Public-by-default with selective gating
const config = defineRbacLayoutConfig({
auth: { enabled: false },
rbac: { enabled: false },
})
// Then use RouteGuard / Protected only on the pages that need it.
<RouteGuard rule={{ roles: ['admin'] }}>
<AdminPanel />
</RouteGuard>Custom user menu
defineRbacLayoutConfig({
layout: {
renderUserMenu: ({ user, logout }) => (
<DropdownMenu>
<DropdownMenuTrigger>{user?.name ?? 'Account'}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem href="/profile">Profile</DropdownMenuItem>
<DropdownMenuItem onSelect={logout}>Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
})Custom brand block
defineRbacLayoutConfig({
layout: {
renderBrand: () => (
<a href="/" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<img src="/logo.svg" width={28} height={28} />
<strong>Acme</strong>
</a>
),
},
})Persist sidebar collapsed state
defineRbacLayoutConfig({
layout: {
sidebar: {
persistKey: 'acme.sidebar.open', // → localStorage
},
},
})API summary
// Provider
<RbacLayoutProvider config={...}>...</RbacLayoutProvider>
// Layout
<AppLayout>{children}</AppLayout>
<Sidebar /> // standalone, advanced
<Header /> // standalone, advanced
<MobileDrawer /> // standalone, advanced
// Guards
<AuthGuard fallback={...}>...</AuthGuard>
<RbacGuard rule={...} fallback={...}>...</RbacGuard>
<RouteGuard rule={...}>...</RouteGuard>
<Protected rule={...}>...</Protected>
// Hooks
useRbacLayout() // full context
useRbac() // { can, hasRole, hasPermission, ... }
useAuthState() // raw auth state
useAuthGuard(rule) // { allowed, loading, reason }
useSidebar() // open/toggle/drawer controls
useLayoutConfig() // resolved config
usePathname() // current path
useRegisterModule({ id, menuItems, headerActions })
useRegisterMenuItems(id, items)
useRegisterHeaderActions(id, actions)
// Config helpers
defineRbacLayoutConfig({...})
resolveConfig({...})
// Token utilities
validateJwt(token, { refreshThresholdSec })
decodeJwtPayload(token)
getJwtExpiresAt(token)
isExpired(timestamp)
resolveExpiresAt(explicit, token)License
MIT — © Econneq
