lib/phoenix_kit/dashboard/ADMIN_README.md

# 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
)
```