Packages
phoenix_kit
2.0.1
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
Current section
Files
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="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)
~H"""
<div class="tab-with-subtabs" data-tab-id={@tab.id} data-has-subtabs={@has_subtabs}>
<TabItem.tab_item
tab={@display_tab}
active={@parent_active}
locale={@locale}
/>
<%= 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
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)
# 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