# PhoenixKit Admin Navigation System
Registry-driven admin sidebar navigation that replaces hardcoded HEEX with configurable, permission-gated Tab structs. Shares the same underlying registry and rendering infrastructure as the [User Dashboard Tab System](README.md).
## How It Works
All admin navigation items are registered as Tab structs in the Dashboard Registry with `level: :admin`. The admin sidebar component reads these tabs, filters by permission and module-enabled status, and renders them using the same `TabItem` component as the user dashboard.
### Three-Layer Visibility
Every admin tab passes through three filters before rendering:
1. **Module Enabled** — Is the feature module active? (e.g., is Billing enabled?)
2. **Permission Granted** — Does the user's role have access? (checked via `Scope.has_module_access?/2`)
3. **Custom Visibility** — Optional `visible` function for special logic
```
Tab registered → module_enabled? → permission_granted? → visible? → rendered
```
## Default Admin Tabs
PhoenixKit registers ~50 admin tabs automatically on startup, organized into three groups:
| Group | Tabs |
|-------|------|
| **Main** | Dashboard, Users (+ 6 subtabs), Media |
| **Modules** | Emails, Billing, Shop, Entities, AI, Sync, DB, Posts, Comments, Publishing, Jobs, Tickets, Modules |
| **System** | Settings (+ ~20 subtabs covering all module settings) |
Each tab has a `permission` field matching one of the 25 permission keys (e.g., `"billing"`, `"users"`, `"settings"`). Tabs for disabled modules are automatically hidden.
## Customizing Admin Tabs
### Adding Tabs via Config
Add custom tabs to the admin sidebar:
```elixir
# config/config.exs
config :phoenix_kit, :admin_dashboard_tabs, [
%{
id: :admin_analytics,
label: "Analytics",
icon: "hero-chart-bar",
path: "analytics",
permission: "dashboard",
priority: 350,
group: :admin_main
}
]
```
> **Tab paths are relative by convention.** `Tab.resolve_path/2` prepends the context prefix at render/compile time — `admin_tabs/0` tabs get `/admin/`, `settings_tabs/0` get `/admin/settings/`, `user_dashboard_tabs/0` get `/dashboard/`. So `path: "analytics"` in an `admin_tabs/0` entry resolves to `/admin/analytics`. Absolute paths (starting with `/`) pass through unchanged, but the relative form is preferred — it's what every real plugin module uses and it lets the same tab definition work across contexts without hardcoding the prefix.
### Adding Tabs with Seamless Navigation
By default, custom tabs are sidebar links only — the parent app must define the actual LiveView routes. If those routes are in a different `live_session`, navigation causes a full page reload.
To avoid this, add the `live_view` field. PhoenixKit will auto-generate the route inside its shared admin `live_session`, giving you seamless LiveView navigation:
```elixir
config :phoenix_kit, :admin_dashboard_tabs, [
%{
id: :admin_analytics,
label: "Analytics",
icon: "hero-chart-bar",
path: "analytics",
permission: "dashboard",
priority: 350,
group: :admin_main,
live_view: {MyAppWeb.AnalyticsLive, :index} # Auto-generates route
}
]
```
With `live_view` set, PhoenixKit:
- Generates `live "/admin/analytics", MyAppWeb.AnalyticsLive, :index` inside the admin `live_session`
- Applies the `:phoenix_kit_ensure_admin` on_mount hook automatically
- Navigation from other admin pages uses LiveView `navigate` (no full page reload)
**Without `live_view`**: Parent app defines routes in its own router (may be a different `live_session`).
### Tab Fields Reference
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `id` | atom | required | Unique identifier (prefix with `admin_` by convention) |
| `label` | string | required | Display text in sidebar |
| `icon` | string | nil | Heroicon name (e.g., `"hero-chart-bar"`) |
| `path` | string | required | URL path — **relative by convention** (e.g., `"analytics"`, resolved to `/admin/analytics` by `Tab.resolve_path/2`). Absolute paths also work but are discouraged |
| `priority` | integer | 500 | Sort order (lower = higher in sidebar) |
| `level` | atom | `:admin` | Set automatically by config loader |
| `permission` | string | nil | Permission key for access control (e.g., `"billing"`) |
| `group` | atom | nil | Group ID: `:admin_main`, `:admin_modules`, or `:admin_system` |
| `parent` | atom | nil | Parent tab ID for subtab relationships |
| `match` | atom | `:prefix` | Path matching: `:exact`, `:prefix`, or `{:regex, ~r/...}` |
| `visible` | function | nil | `(scope -> boolean)` for non-permission conditional logic (feature flags, user data). For access control, use `permission` instead. |
| `live_view` | tuple | nil | `{Module, :action}` to auto-generate a route |
| `subtab_display` | atom | `:when_active` | `:when_active` or `:always` |
| `highlight_with_subtabs` | boolean | false | Highlight parent when subtab is active |
| `dynamic_children` | function | nil | `(scope -> [Tab.t()])` for runtime subtabs |
### Modifying Default Tabs
Update or remove default tabs at runtime:
```elixir
# Change a default tab's label or icon
PhoenixKit.Dashboard.update_tab(:admin_dashboard, %{label: "Home", icon: "hero-home"})
# Remove a default tab
PhoenixKit.Dashboard.unregister_tab(:admin_jobs)
```
### Registering Tabs at Runtime
```elixir
# Register admin tabs programmatically (level: :admin is set automatically)
PhoenixKit.Dashboard.register_admin_tabs(:my_app, [
%{
id: :admin_analytics,
label: "Analytics",
icon: "hero-chart-bar",
path: "analytics",
permission: "dashboard",
priority: 350,
group: :admin_main
}
])
# Unregister all tabs for a namespace
PhoenixKit.Dashboard.unregister_tabs(:my_app)
```
## Subtabs
Admin tabs support parent/child relationships, working the same as [user dashboard subtabs](README.md#subtabs):
```elixir
config :phoenix_kit, :admin_dashboard_tabs, [
# Parent
%{
id: :admin_reports,
label: "Reports",
icon: "hero-document-chart-bar",
path: "reports",
permission: "dashboard",
priority: 360,
group: :admin_main,
subtab_display: :when_active,
live_view: {MyAppWeb.ReportsLive, :index}
},
# Subtabs
%{
id: :admin_reports_sales,
label: "Sales",
path: "reports/sales",
parent: :admin_reports,
priority: 361,
live_view: {MyAppWeb.ReportsSalesLive, :index}
},
%{
id: :admin_reports_users,
label: "Users",
path: "reports/users",
parent: :admin_reports,
priority: 362,
live_view: {MyAppWeb.ReportsUsersLive, :index}
}
]
```
## Dynamic Children
Some admin tabs generate subtabs at render time based on data:
- **Entities** — A subtab for each published entity type
- **Publishing** — A subtab for each publishing group from settings
These use the `dynamic_children` field — a function `(scope -> [Tab.t()])` called when the sidebar renders. Dynamic children are always rendered under their parent tab and inherit its permission.
### Custom Dynamic Children
```elixir
PhoenixKit.Dashboard.register_admin_tabs(:my_app, [
%{
id: :admin_workspaces,
label: "Workspaces",
icon: "hero-squares-2x2",
path: "workspaces",
permission: "dashboard",
priority: 400,
group: :admin_main,
dynamic_children: fn _scope ->
MyApp.Workspaces.list_active()
|> Enum.with_index()
|> Enum.map(fn {ws, idx} ->
%PhoenixKit.Dashboard.Tab{
id: :"admin_workspace_#{ws.slug}",
label: ws.name,
icon: "hero-square-2-stack",
path: "workspaces/#{ws.slug}",
priority: 401 + idx,
level: :admin,
permission: "dashboard",
match: :prefix,
parent: :admin_workspaces
}
end)
end
}
])
```
**Performance note**: Dynamic children functions run on every sidebar render (each navigation). Keep them fast — use cached data, avoid expensive queries.
## Permission System
Admin tabs integrate with PhoenixKit's module-level permissions (`PhoenixKit.Users.Permissions`):
- **Owner** — Always has full access (hardcoded, no DB rows needed)
- **Admin** — Gets all 25 built-in permissions by default
- **Custom roles** — Start with no permissions; grant via matrix UI or API
### Built-in Permission Keys
The `permission` field on a tab can use any of the 25 built-in keys:
**Core (always enabled):** `dashboard`, `users`, `media`, `settings`, `modules`
**Feature modules (enabled/disabled):** `billing`, `shop`, `emails`, `entities`, `tickets`, `posts`, `comments`, `ai`, `sync`, `publishing`, `referrals`, `sitemap`, `seo`, `maintenance`, `storage`, `languages`, `connections`, `legal`, `db`, `jobs`
When a tab's `permission` points to a feature module:
- If the module is **disabled**, the tab is hidden for everyone
- If the module is **enabled**, the tab is shown only to users whose role has that permission
### Custom Permission Keys (Auto-Registration)
When a custom admin tab uses a permission key that isn't one of the 25 built-in keys, PhoenixKit **automatically registers it** as a custom permission. The key appears in the permission matrix and roles popup under an **Custom** section, where it can be granted or revoked per role — just like built-in permissions.
```elixir
config :phoenix_kit, :admin_dashboard_tabs, [
%{
id: :admin_analytics,
label: "Analytics",
icon: "hero-chart-bar",
path: "analytics",
permission: "analytics", # Not a built-in key → auto-registered
group: :admin_main,
live_view: {MyAppWeb.AnalyticsLive, :index}
}
]
```
**What happens automatically:**
1. `"analytics"` is registered as a custom permission key with label and icon from the tab config
2. It appears in the permission matrix and roles popup under **Custom**
3. Owner gets automatic access (Owner always gets all keys, including custom ones)
4. The tab is treated as "always enabled" (custom keys have no module toggle)
5. The LiveView module → permission mapping is cached for auth enforcement on mount
**Custom keys must** match `~r/^[a-z][a-z0-9_]*$/`. Using a built-in key name raises `ArgumentError`.
### Subtab Permission Inheritance
Subtabs inherit access from their parent tab's permission. When a parent tab is hidden (user lacks its permission), all its subtabs are hidden too — no separate permission needed:
```elixir
config :phoenix_kit, :admin_dashboard_tabs, [
# Parent — requires "analytics" permission
%{
id: :admin_analytics,
label: "Analytics",
icon: "hero-chart-bar",
path: "analytics",
permission: "analytics",
priority: 350,
group: :admin_main,
live_view: {MyAppWeb.AnalyticsLive, :index}
},
# Subtabs — no permission field needed, inherit from parent
%{
id: :admin_analytics_sales,
label: "Sales",
path: "analytics/sales",
parent: :admin_analytics,
priority: 351,
live_view: {MyAppWeb.AnalyticsSalesLive, :index}
},
%{
id: :admin_analytics_traffic,
label: "Traffic",
path: "analytics/traffic",
parent: :admin_analytics,
priority: 352,
live_view: {MyAppWeb.AnalyticsTrafficLive, :index}
}
]
```
If a subtab needs its own independent permission, it can set a `permission` field — this will auto-register a separate custom key:
```elixir
%{
id: :admin_analytics_billing,
label: "Billing Reports",
path: "analytics/billing",
parent: :admin_analytics,
permission: "analytics_billing", # Separate permission, auto-registered
priority: 353
}
```
### Programmatic Registration
Custom permission keys can also be registered directly, independent of tabs:
```elixir
PhoenixKit.Users.Permissions.register_custom_key("analytics",
label: "Analytics",
icon: "hero-chart-bar",
description: "Analytics dashboard and reports"
)
```
### Granting Custom Permissions
Custom permissions work exactly like built-in ones:
```elixir
# Via API
Permissions.grant_permission(role_uuid, "analytics", granted_by_uuid)
# Via set_permissions (includes custom keys)
Permissions.set_permissions(role_uuid, ["dashboard", "users", "analytics"], granted_by_uuid)
# Grant all (includes custom keys)
Permissions.grant_all_permissions(role_uuid, granted_by_uuid)
```
Or use the admin UI: navigate to the permission matrix or the role's permission editor — custom keys appear under the **Custom** section.
## Navigation Architecture
### LiveView Sessions
All PhoenixKit admin routes share a single `live_session`:
```elixir
live_session :phoenix_kit_admin,
on_mount: [{PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_admin}] do
# All admin routes — PhoenixKit core + modules + custom (with live_view)
end
```
This means:
- Navigating between admin pages uses LiveView `navigate` (WebSocket stays alive)
- Each page does a lightweight MOUNT (expected behavior for different LiveView modules)
- No full page reloads within the admin panel
**Important**: Hand-writing `live` routes for admin LiveViews in your parent router puts them in a different `live_session` than `:phoenix_kit_admin`, which causes two problems: (1) the admin layout is lost (the sidebar/header are applied by `:phoenix_kit_ensure_admin` which only runs inside `:phoenix_kit_admin`), and (2) navigating from another admin page tears down the WebSocket with `navigate event failed because you are redirecting across live_sessions. A full page reload will be performed instead`. You cannot work around this by redeclaring `live_session :phoenix_kit_admin` in your router — Phoenix raises on duplicate live_session names. **Always register custom pages via `live_view:` on a tab** so PhoenixKit compiles them into the shared admin live_session. See `phoenix_kit/guides/custom-admin-pages.md` for the authoritative reference.
### Tab Rendering Flow
```
1. Registry.get_admin_tabs(scope: scope)
├── Filter by level (:admin + :all)
├── Filter by module enabled (deduplicated per permission key)
├── Filter by permission (in-memory MapSet check)
└── Filter by visibility (custom functions)
2. AdminSidebar component
├── Expand dynamic children (entities, publishing)
├── Add active state based on current_path
├── Group tabs by group field
└── Render via TabItem component (shared with user dashboard)
```
**Important**: Dynamic children are expanded *before* active state is applied, so that dynamically-generated subtabs (e.g., individual entity types) correctly highlight when navigated to.
## API Reference
```elixir
# Admin-specific
PhoenixKit.Dashboard.get_admin_tabs(opts) # Get filtered admin tabs
PhoenixKit.Dashboard.get_user_tabs(opts) # Get filtered user tabs
PhoenixKit.Dashboard.register_admin_tabs(ns, tabs) # Register with level: :admin
PhoenixKit.Dashboard.update_tab(tab_id, attrs) # Modify existing tab
PhoenixKit.Dashboard.load_admin_defaults() # Reload default admin tabs
# All standard Dashboard APIs also work (see README.md)
PhoenixKit.Dashboard.unregister_tab(tab_id)
PhoenixKit.Dashboard.get_tab(tab_id)
# etc.
```
## File Structure
```
lib/phoenix_kit/dashboard/
├── admin_tabs.ex # Default admin tab definitions (~50 tabs)
├── dashboard.ex # Public API facade
├── registry.ex # Tab registry GenServer (shared user + admin)
├── tab.ex # Tab struct with level/permission/dynamic_children fields
├── ADMIN_README.md # This file
└── README.md # User dashboard documentation
lib/phoenix_kit_web/components/dashboard/
├── admin_sidebar.ex # Admin sidebar component
├── sidebar.ex # User dashboard sidebar component
├── tab_item.ex # Shared tab rendering component
└── ...
```
## Creating Custom Admin Pages
When using the `live_view` field, your LiveView runs inside PhoenixKit's admin `live_session` and must use the admin layout. Here's the complete pattern:
### 1. Create the LiveView
```elixir
# lib/my_app_web/phoenix_kit_live/admin_analytics_live.ex
defmodule MyAppWeb.PhoenixKitLive.AdminAnalyticsLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, page_title: "Analytics")}
end
def render(assigns) do
~H"""
<PhoenixKitWeb.Components.LayoutWrapper.app_layout
flash={@flash}
page_title={@page_title}
current_path={@url_path}
phoenix_kit_current_scope={@phoenix_kit_current_scope}
current_locale={assigns[:current_locale]}
>
<div class="container flex-col mx-auto px-4 py-6">
<h1 class="text-2xl font-bold mb-6">Analytics Dashboard</h1>
<%!-- Your content here --%>
</div>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>
"""
end
end
```
### 2. Register the Tab
```elixir
# config/config.exs
config :phoenix_kit, :admin_dashboard_tabs, [
%{
id: :admin_analytics,
label: "Analytics",
icon: "hero-chart-bar",
path: "analytics",
permission: "dashboard",
priority: 150,
group: :admin_main,
live_view: {MyAppWeb.PhoenixKitLive.AdminAnalyticsLive, :index}
}
]
```
### Key Points
- **Use `@url_path` not `@current_path`** — The `url_path` assign is set by PhoenixKit's `on_mount` hooks. There is no `current_path` assign.
- **Use `LayoutWrapper.app_layout`** — This is the admin layout with the admin sidebar. Do NOT use `Layouts.dashboard` (that's the user dashboard layout).
- **Don't pass `project_title`** — The `app_layout` component has a built-in default; passing it from the LiveView will crash since it's not in the assigns.
- **Use `assigns[:current_locale]`** — Use bracket access for optional assigns that may not be set.
- **Place LiveViews under `phoenix_kit_live/`** — Convention for LiveViews that run inside PhoenixKit's admin `live_session`.
### Available Assigns
These assigns are automatically set by PhoenixKit's `on_mount` hooks in the admin `live_session`:
| Assign | Type | Description |
|--------|------|-------------|
| `@url_path` | string | Current URL path (use for `current_path` in layout) |
| `@phoenix_kit_current_scope` | Scope.t() | Auth scope with user, roles, and permissions |
| `@phoenix_kit_current_user` | User.t() | Current authenticated user |
| `@current_locale` | string | Current locale code (may be nil) |
| `@flash` | map | Flash messages |
| `@live_action` | atom | The action from the route (e.g., `:index`) |
## Legacy Config Compatibility
The legacy `AdminDashboardCategories` config format is still supported but deprecated:
```elixir
# Legacy format (deprecated, will log warning)
config :phoenix_kit, AdminDashboardCategories, [
%{title: "Custom", icon: "hero-star", tabs: [
%{title: "Analytics", url: "/admin/analytics", icon: "hero-chart-bar"}
]}
]
# New format (recommended)
config :phoenix_kit, :admin_dashboard_tabs, [
%{id: :admin_analytics, label: "Analytics", icon: "hero-chart-bar",
path: "analytics", permission: "dashboard", group: :admin_main}
]
```
Legacy categories are automatically converted to admin Tab structs at startup. A deprecation warning is logged when legacy config is detected.
## Important: Compile-Time Behavior
The `live_view` field is evaluated **at compile time**. Routes for custom admin tabs are generated during compilation of the router.
### What this means
1. The LiveView module referenced in `live_view` must exist and compile successfully
2. If the module doesn't compile, the route is silently skipped (a warning is emitted)
3. Routes are baked into the compiled router — they won't update until recompilation
### After changing `:admin_dashboard_tabs` config
```bash
mix compile --force
```
Without `--force`, the router may not recompile and your tab changes won't take effect.
### Troubleshooting
**"My custom tab appears in the sidebar but links to a 404"**
- The LiveView module may not have been compiled when the router compiled
- Run `mix compile --force` to regenerate routes
**"My custom tab doesn't appear at all"**
1. Verify the tab config is correct (has `id`, `label`, `path`, `permission`)
2. Check that the module is enabled (if permission maps to a feature module)
3. Check that the user's role has the required permission
4. Check `mix compile --force` was run after config changes
**"Navigation causes a full page reload"**
- The tab is missing the `live_view` field, so PhoenixKit can't generate a route in its admin `live_session`
- Add `live_view: {MyModule, :index}` to enable seamless navigation
## Telemetry
The admin sidebar emits telemetry events for performance monitoring:
- `[:phoenix_kit, :admin_sidebar, :render, :start]` — emitted when sidebar rendering begins
- `[:phoenix_kit, :admin_sidebar, :render, :stop]` — emitted when rendering completes (includes `tab_count` in metadata)
```elixir
:telemetry.attach("admin-sidebar-monitor",
[:phoenix_kit, :admin_sidebar, :render, :stop],
fn _event, measurements, metadata, _config ->
Logger.debug("Admin sidebar rendered #{metadata.tab_count} tabs in #{measurements.duration}ns")
end,
nil
)
```