PhoenixKit Admin Navigation System

Copy Markdown View Source

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.

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:

GroupTabs
MainDashboard, Users (+ 6 subtabs), Media
ModulesEmails, Billing, Shop, Entities, AI, Sync, DB, Posts, Comments, Publishing, Jobs, Tickets, Modules
SystemSettings (+ ~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:

# config/config.exs
config :phoenix_kit, :admin_dashboard_tabs, [
  %{
    id: :admin_analytics,
    label: "Analytics",
    icon: "hero-chart-bar",
    path: "/admin/analytics",
    permission: "dashboard",
    priority: 350,
    group: :admin_main
  }
]

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:

config :phoenix_kit, :admin_dashboard_tabs, [
  %{
    id: :admin_analytics,
    label: "Analytics",
    icon: "hero-chart-bar",
    path: "/admin/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

FieldTypeDefaultDescription
idatomrequiredUnique identifier (prefix with admin_ by convention)
labelstringrequiredDisplay text in sidebar
iconstringnilHeroicon name (e.g., "hero-chart-bar")
pathstringrequiredURL path without prefix (e.g., "/admin/analytics")
priorityinteger500Sort order (lower = higher in sidebar)
levelatom:adminSet automatically by config loader
permissionstringnilPermission key for access control (e.g., "billing")
groupatomnilGroup ID: :admin_main, :admin_modules, or :admin_system
parentatomnilParent tab ID for subtab relationships
matchatom:prefixPath matching: :exact, :prefix, or {:regex, ~r/...}
visiblefunctionnil(scope -> boolean) for non-permission conditional logic (feature flags, user data). For access control, use permission instead.
live_viewtuplenil{Module, :action} to auto-generate a route
subtab_displayatom:when_active:when_active or :always
highlight_with_subtabsbooleanfalseHighlight parent when subtab is active
dynamic_childrenfunctionnil(scope -> [Tab.t()]) for runtime subtabs

Modifying Default Tabs

Update or remove default tabs at runtime:

# 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

# 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: "/admin/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:

config :phoenix_kit, :admin_dashboard_tabs, [
  # Parent
  %{
    id: :admin_reports,
    label: "Reports",
    icon: "hero-document-chart-bar",
    path: "/admin/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: "/admin/reports/sales",
    parent: :admin_reports,
    priority: 361,
    live_view: {MyAppWeb.ReportsSalesLive, :index}
  },
  %{
    id: :admin_reports_users,
    label: "Users",
    path: "/admin/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

PhoenixKit.Dashboard.register_admin_tabs(:my_app, [
  %{
    id: :admin_workspaces,
    label: "Workspaces",
    icon: "hero-squares-2x2",
    path: "/admin/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: "/admin/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.

config :phoenix_kit, :admin_dashboard_tabs, [
  %{
    id: :admin_analytics,
    label: "Analytics",
    icon: "hero-chart-bar",
    path: "/admin/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:

config :phoenix_kit, :admin_dashboard_tabs, [
  # Parent — requires "analytics" permission
  %{
    id: :admin_analytics,
    label: "Analytics",
    icon: "hero-chart-bar",
    path: "/admin/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: "/admin/analytics/sales",
    parent: :admin_analytics,
    priority: 351,
    live_view: {MyAppWeb.AnalyticsSalesLive, :index}
  },
  %{
    id: :admin_analytics_traffic,
    label: "Traffic",
    path: "/admin/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:

%{
  id: :admin_analytics_billing,
  label: "Billing Reports",
  path: "/admin/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:

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:

# 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.

LiveView Sessions

All PhoenixKit admin routes share a single live_session:

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: Custom admin routes defined by the parent app WITHOUT live_view may be in a different live_session, which would cause a full page reload when navigating to them. Use live_view in your tab config to avoid this.

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

# 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

# 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

# config/config.exs
config :phoenix_kit, :admin_dashboard_tabs, [
  %{
    id: :admin_analytics,
    label: "Analytics",
    icon: "hero-chart-bar",
    path: "/admin/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:

AssignTypeDescription
@url_pathstringCurrent URL path (use for current_path in layout)
@phoenix_kit_current_scopeScope.t()Auth scope with user, roles, and permissions
@phoenix_kit_current_userUser.t()Current authenticated user
@current_localestringCurrent locale code (may be nil)
@flashmapFlash messages
@live_actionatomThe action from the route (e.g., :index)
@show_maintenancebooleanWhether maintenance mode banner is shown

Legacy Config Compatibility

The legacy AdminDashboardCategories config format is still supported but deprecated:

# 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: "/admin/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

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)
: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
)