Packages

phoenix_kit

2.22.20
2.63.0 2.62.2 2.62.1 2.62.0 2.61.0 2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web components dashboard admin_sidebar.ex
Raw

lib/phoenix_kit_web/components/dashboard/admin_sidebar.ex

defmodule PhoenixKitWeb.Components.Dashboard.AdminSidebar do
  @moduledoc """
  Admin sidebar component for the PhoenixKit admin panel.

  Renders the admin navigation using registry-driven Tab structs instead of
  hardcoded HEEX. Supports:
  - Permission-gated tabs (filtered by Registry)
  - Module-enabled filtering (filtered by Registry)
  - Dynamic children for Entities and Publishing
  - Subtab expand/collapse
  - Full reuse of the TabItem component for consistent rendering

  ## An entry is rendered only if the visitor can open it

  The menu must never offer a page that bounces the visitor on arrival, so
  every entry is filtered against the SAME gates its destination enforces —
  see `reachable_tabs/2`. This matters because `/admin` is the guaranteed
  landing for every authenticated user: a visitor with no permissions at all
  now renders this shell, where before only a permission holder could.

  The registry already drops a tab whose `:permission` key the scope does not
  hold (`PhoenixKit.Dashboard.Registry.get_tabs/1` → `Tab.permission_granted?/2`) and a tab whose
  `:visible` function says no. Two gates it does NOT apply are added here:

    * `Scope.can_access_admin_area?/1`, the first thing
      `:phoenix_kit_ensure_admin` checks. Fail it and EVERY `/admin` page
      redirects you — including the personal ones — so the whole menu is empty.
    * `PhoenixKitWeb.Users.Auth.can_access_admin_view?/2` for an entry that
      names a `live_view:`. A tab may name a view and no permission key; the
      mount gate then treats that view as *unmapped* and admits only a scope
      holding every enabled permission, while the sidebar happily linked it for
      everyone.

  ## Usage

      <.admin_sidebar
        current_path={@current_path}
        scope={@phoenix_kit_current_scope}
        locale={@current_locale}
      />
  """

  use Phoenix.Component

  require Logger

  alias PhoenixKit.Dashboard.{Group, Registry, Tab}
  alias PhoenixKit.Users.Auth.Scope
  alias PhoenixKitWeb.Components.Dashboard.TabItem
  alias PhoenixKitWeb.Users.Auth

  import PhoenixKit.Dashboard.TabHelpers
  import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1]

  @doc """
  Renders the complete admin sidebar navigation.

  ## Attributes

  - `current_path` - The current URL path for active state detection
  - `scope` - The current authentication scope for permission filtering
  - `locale` - The current locale for path generation
  - `class` - Additional CSS classes
  """
  attr :current_path, :string, default: "/admin"
  attr :scope, :any, default: nil
  attr :locale, :string, default: nil
  attr :class, :string, default: ""

  def admin_sidebar(assigns) do
    # Get admin tabs, already filtered by level, permission, and module-enabled
    # Expand dynamic children BEFORE active state so dynamic tabs get checked too
    tabs =
      :telemetry.span([:phoenix_kit, :admin_sidebar, :render], %{}, fn ->
        result =
          assigns.scope
          |> admin_tabs_for_scope(assigns[:locale])
          |> add_active_state(assigns.current_path)

        {result, %{tab_count: length(result)}}
      end)

    # Group tabs
    grouped_tabs = group_tabs(tabs)
    groups = Registry.get_groups()

    assigns =
      assigns
      |> assign(:tabs, tabs)
      |> assign(:grouped_tabs, grouped_tabs)
      |> assign(:groups, groups)

    ~H"""
    <%!-- No `<nav>` at all when nothing survived the gates: an empty
          navigation landmark is worse than none, and a shell that offers a
          visitor zero destinations should render zero chrome. --%>
    <nav
      :if={@tabs != []}
      class={["space-y-2", @class]}
      role="navigation"
      aria-label="Admin navigation"
    >
      <%= for group <- sorted_groups(@groups, @grouped_tabs) do %>
        <.admin_tab_group
          group={group}
          tabs={Map.get(@grouped_tabs, group.id, [])}
          all_tabs={@tabs}
          locale={@locale}
        />
      <% end %>

      <%!-- Render ungrouped tabs --%>
      <%= for tab <- filter_top_level(Map.get(@grouped_tabs, nil, [])) do %>
        <.admin_tab_with_subtabs
          tab={tab}
          all_tabs={@tabs}
          locale={@locale}
        />
      <% end %>
    </nav>
    """
  end

  @doc """
  Keeps only the entries `scope` can actually open.

  Two gates, in the order the mount hook applies them:

  1. `Scope.can_access_admin_area?/1` — the admin-area gate
     `:phoenix_kit_ensure_admin` checks before anything else. A scope that
     fails it (a `nil` scope, or an authenticated user holding no permission at
     all) is redirected off every `/admin` page, personal ones included, so the
     answer is `[]` — not "the tabs with no permission key".
  2. `PhoenixKitWeb.Users.Auth.can_access_admin_view?/2` for any entry naming a
     `live_view:` — the same function the mount gate asks. Nothing is restated
     here, so a rendered entry and its destination cannot disagree.

  An entry with no `live_view:` is left to the registry's own `:permission` /
  `:visible` filtering — `PhoenixKit.Dashboard.Registry.get_admin_tabs/1` applies both before the
  sidebar calls this. Every tab core ships is of that shape: core declares its
  admin routes in the router rather than on the tab, so there is no module to
  ask, and gate 2 is a structural no-op over core's own menu.
  """
  @spec reachable_tabs([Tab.t()], Scope.t() | nil) :: [Tab.t()]
  def reachable_tabs(tabs, scope) do
    if Scope.can_access_admin_area?(scope) do
      Enum.filter(tabs, &reachable?(&1, scope))
    else
      []
    end
  end

  # The admin-area gate is answered BEFORE the registry is consulted: building
  # a menu for a visitor who may see none of it would run `feature_enabled?/1`
  # per permission key and every module's `dynamic_children` callback, only to
  # discard the result. `reachable_tabs/2` re-applies the gate because it is
  # public and must be safe on its own — the second check is a MapSet size test.
  defp admin_tabs_for_scope(scope, locale) do
    if Scope.can_access_admin_area?(scope) do
      Registry.get_admin_tabs(scope: scope)
      |> expand_dynamic_children(scope, locale)
      |> reachable_tabs(scope)
    else
      []
    end
  end

  defp reachable?(%{live_view: {view, _action}}, scope) when is_atom(view),
    do: Auth.can_access_admin_view?(scope, view)

  defp reachable?(%{live_view: view}, scope) when is_atom(view) and not is_nil(view),
    do: Auth.can_access_admin_view?(scope, view)

  defp reachable?(_tab, _scope), do: true

  attr :group, :map, required: true
  attr :tabs, :list, required: true
  attr :all_tabs, :list, required: true
  attr :locale, :string, default: nil

  defp admin_tab_group(assigns) do
    # `sorted_groups/2` keeps a group that still holds ANY tab, but only
    # top-level tabs render here — a group left with nothing but subtabs whose
    # parents the gates removed would otherwise emit its heading (and its
    # spacing wrapper) above nothing.
    assigns = assign(assigns, :top_level_tabs, filter_top_level(assigns.tabs))

    ~H"""
    <div :if={@top_level_tabs != []} class="space-y-1" data-group-id={@group.id}>
      <%= if Group.localized_label(@group) do %>
        <div class="pk-sidebar-group px-3 py-2 text-xs font-semibold text-base-content/50 uppercase tracking-wider">
          <span class="flex items-center gap-2">
            <%= if @group.icon do %>
              <.icon name={@group.icon} class="w-3.5 h-3.5" />
            <% end %>
            {Group.localized_label(@group)}
          </span>
        </div>
      <% end %>

      <%= for tab <- @top_level_tabs do %>
        <.admin_tab_with_subtabs
          tab={tab}
          all_tabs={@all_tabs}
          locale={@locale}
        />
      <% end %>
    </div>
    """
  end

  attr :tab, :any, required: true
  attr :all_tabs, :list, required: true
  attr :locale, :string, default: nil

  defp admin_tab_with_subtabs(assigns) do
    subtabs = get_subtabs_for(assigns.tab.id, assigns.all_tabs)
    # Check all descendants (not just direct children) for active state
    descendant_active = any_descendant_active?(assigns.tab.id, assigns.all_tabs)

    show_subtabs =
      Tab.show_subtabs?(assigns.tab, assigns.tab.active) or descendant_active

    display_tab = maybe_redirect_to_first_subtab(assigns.tab, subtabs)

    highlight_with_subtabs = Map.get(assigns.tab, :highlight_with_subtabs, false)

    parent_active =
      if descendant_active and not highlight_with_subtabs do
        false
      else
        assigns.tab.active
      end

    assigns =
      assigns
      |> assign(:subtabs, subtabs)
      |> assign(:show_subtabs, show_subtabs)
      |> assign(:has_subtabs, subtabs != [])
      |> assign(:display_tab, display_tab)
      |> assign(:parent_active, parent_active)
      # Dividers and group headers render no link, so there is nothing to hover
      # and nothing to name.
      |> assign(:flyout_id, flyout_id(assigns.tab))
      # Stated for the collapsed rail, which has no other way to show which
      # section you are in: the highlight normally lives on the active SUBTAB,
      # and compact mode hides the subtab list. Rendered unconditionally and
      # acted on by CSS only when collapsed, because whether the rail is
      # collapsed is client state the server cannot see.
      |> assign(:branch_active, to_string(descendant_active))

    ~H"""
    <div
      class="tab-with-subtabs"
      data-tab-id={@tab.id}
      data-has-subtabs={@has_subtabs}
      data-pk-flyout-id={@flyout_id}
      data-pk-branch-active={@branch_active}
    >
      <TabItem.tab_item
        tab={@display_tab}
        active={@parent_active}
        locale={@locale}
      />

      <%!-- Compact-mode flyout. Rendered for every navigable top-level entry,
            NOT only the active one: `subtab_display` defaults to `:when_active`,
            so the inline list below exists only for the section you are already
            in — which is precisely the browsing the icon rail otherwise loses.

            Always in the DOM and inert until opened: `popover` is
            `display: none` until `showPopover()`, so this costs markup rather
            than behaviour, and only the client knows whether the rail is
            collapsed.

            `popover="auto"` earns its keep three times over — the top layer
            escapes the sidebar's own `overflow-y: auto` clipping (an absolutely
            positioned panel is cut off at the rail's edge, which is what killed
            the hover tooltip this replaces), light-dismiss and Esc come free,
            and only one auto popover can be open, so moving between icons
            closes the previous flyout without any bookkeeping.

            Placed immediately after the link so Tab order walks into it: the
            top layer changes PAINTING, not the DOM tree, so focus order — and
            every descendant CSS selector — still follows this position. --%>
      <div
        :if={@flyout_id}
        id={@flyout_id}
        popover="auto"
        class="pk-sidebar-flyout"
        role="group"
        aria-label={Tab.localized_label(@tab)}
      >
        <div class="pk-sidebar-flyout-title">{Tab.localized_label(@tab)}</div>
        <%= for subtab <- @subtabs do %>
          <TabItem.tab_item tab={subtab} active={subtab.active} locale={@locale} />
        <% end %>
      </div>

      <%= if @has_subtabs and @show_subtabs do %>
        <div class="subtabs pl-1 border-l-2 border-base-300 ml-2 mt-1 space-y-0.5">
          <%= for subtab <- @subtabs do %>
            <.admin_subtab_item
              subtab={subtab}
              parent_tab={@tab}
              all_tabs={@all_tabs}
              locale={@locale}
            />
          <% end %>
        </div>
      <% end %>
    </div>
    """
  end

  # A leaf entry gets a flyout too — holding just its name. In compact mode the
  # label is off-screen for every row, so without this the rail is unreadable
  # for exactly the items that have no children to explain them.
  defp flyout_id(tab) do
    if Tab.navigable?(tab), do: "pk-flyout-#{tab.id}"
  end

  attr :subtab, :any, required: true
  attr :parent_tab, :any, required: true
  attr :all_tabs, :list, required: true
  attr :locale, :string, default: nil

  defp admin_subtab_item(assigns) do
    children = get_subtabs_for(assigns.subtab.id, assigns.all_tabs)
    child_active = any_descendant_active?(assigns.subtab.id, assigns.all_tabs)

    show_children =
      children != [] and
        (Tab.show_subtabs?(assigns.subtab, assigns.subtab.active) or child_active)

    highlight_with_subtabs = Map.get(assigns.subtab, :highlight_with_subtabs, false)

    subtab_active =
      if child_active and not highlight_with_subtabs do
        false
      else
        assigns.subtab.active
      end

    # A subtab may itself carry `redirect_to_first_subtab: true` (a 3-level
    # section like Settings › Integrations). Point its link at the first child
    # the user can reach, while keeping active-state off the original subtab's
    # own match — the same split the top-level tab uses.
    display_subtab = maybe_redirect_to_first_subtab(assigns.subtab, children)

    assigns =
      assigns
      |> assign(:children, children)
      |> assign(:show_children, show_children)
      |> assign(:subtab_active, subtab_active)
      |> assign(:display_subtab, display_subtab)

    ~H"""
    <TabItem.tab_item
      tab={@display_subtab}
      active={@subtab_active}
      locale={@locale}
      parent_tab={@parent_tab}
    />
    <%= if @show_children do %>
      <div class="sub-subtabs pl-1 border-l-2 border-base-300 ml-2 mt-0.5 space-y-0.5">
        <%= for child <- @children do %>
          <TabItem.tab_item
            tab={child}
            active={child.active}
            locale={@locale}
            parent_tab={@subtab}
          />
        <% end %>
      </div>
    <% end %>
    """
  end

  # --- Helpers ---

  defp expand_dynamic_children(tabs, scope, locale) do
    # Find tabs with dynamic_children (arity 1 or 2) and expand them.
    # The 2-arity variant receives locale so modules can render translated
    # child labels without falling back to `Gettext.get_locale/1`.
    {parents_with_dynamic, other_tabs} =
      Enum.split_with(tabs, fn tab ->
        is_function(tab.dynamic_children, 1) or is_function(tab.dynamic_children, 2)
      end)

    dynamic_children =
      Enum.flat_map(parents_with_dynamic, fn parent ->
        children =
          try do
            invoke_dynamic_children(parent.dynamic_children, scope, locale)
          rescue
            error ->
              Logger.warning(
                "[AdminSidebar] dynamic_children for #{inspect(parent.id)} failed: #{Exception.message(error)}"
              )

              []
          end

        # Ensure children have parent set, correct level, and resolved paths
        Enum.map(children, fn child ->
          child
          |> Map.put(:parent, child.parent || parent.id)
          |> Map.put(:level, :admin)
          |> Tab.resolve_path(:admin)
        end)
      end)

    # Active state is applied after this function by add_active_state/2
    other_tabs ++ parents_with_dynamic ++ dynamic_children
  end

  # Dispatches on arity so modules can opt in to locale-aware rendering
  # without breaking existing 1-arity `dynamic_children` implementations.
  defp invoke_dynamic_children(fun, scope, locale) when is_function(fun, 2),
    do: fun.(scope, locale)

  defp invoke_dynamic_children(fun, scope, _locale) when is_function(fun, 1),
    do: fun.(scope)

  @doc false
  # Test-only public delegate. The internal dispatch helper is `defp` because
  # it shouldn't be part of the runtime API; this `@doc false` wrapper lets
  # the unit suite exercise the actual arity dispatch (rather than just
  # re-asserting Elixir's call semantics on anonymous functions). Not
  # recommended for runtime callers — the contract is `dynamic_children_fn`,
  # not this wrapper.
  def __invoke_dynamic_children_for_test__(fun, scope, locale),
    do: invoke_dynamic_children(fun, scope, locale)

  # Test-only public delegate, same reasoning as above. `admin_sidebar/1` needs
  # a started Dashboard Registry to have any tabs at all, which the DB-free
  # component suite does not run; this renders one row from literal tabs so the
  # compact-mode flyout markup can be pinned without that machinery.
  @doc false
  def __tab_with_subtabs_for_test__(assigns), do: admin_tab_with_subtabs(assigns)

  # Recursively checks if any descendant (children, grandchildren, etc.) is active.
  # Includes depth limit and cycle detection for safety with parent-app-registered tabs.
  defp any_descendant_active?(parent_id, all_tabs, depth \\ 0, visited \\ %{})

  defp any_descendant_active?(_parent_id, _all_tabs, depth, _visited) when depth > 5, do: false

  defp any_descendant_active?(parent_id, all_tabs, depth, visited) do
    if Map.has_key?(visited, parent_id) do
      Logger.warning("[AdminSidebar] Circular tab reference detected: #{inspect(parent_id)}")
      false
    else
      children = get_subtabs_for(parent_id, all_tabs)
      new_visited = Map.put(visited, parent_id, true)

      Enum.any?(children, fn child ->
        child.active or any_descendant_active?(child.id, all_tabs, depth + 1, new_visited)
      end)
    end
  end

  defp maybe_redirect_to_first_subtab(%{redirect_to_first_subtab: true} = tab, [
         first_subtab | _
       ]) do
    %{tab | path: first_subtab.path}
  end

  defp maybe_redirect_to_first_subtab(tab, _subtabs), do: tab
end