defmodule PhoenixKitWeb.Components.UserDashboardNav do
@moduledoc """
User dashboard navigation components for the PhoenixKit user dashboard.
Provides navigation elements specifically for user dashboard pages.
"""
use PhoenixKitWeb, :html
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Languages.DialectMapper
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Users.OAuthAvailability
alias PhoenixKit.Utils.Routes
alias PhoenixKitWeb.Components.Core.AdminLabel
alias PhoenixKitWeb.Components.Core.LanguageSwitcher
# Guest dropdown link catalog. Each entry is
# `{key, path, icon, label_fn, setting_gate_fn}`; `label_fn`/`gate_fn`
# are zero-arity so gettext + settings are evaluated at render time
# (per-request locale / live setting), not at compile time.
@guest_link_catalog [
{:login, "/users/log-in", "hero-arrow-right-on-rectangle"},
{:register, "/users/register", "hero-user-plus"},
{:reset, "/users/reset-password", "hero-key"},
{:magic_link, "/users/magic-link", "hero-sparkles"}
]
@doc """
Renders the user widget for dashboard navigation.
For authenticated visitors this is the avatar dropdown (email, the admin
area, Settings, language switcher, log out). For anonymous visitors the
same dropdown *shape* is rendered with a generic "not signed in" icon and
guest-relevant links (log in, sign up, forgot password, magic link) plus
the same language switcher — so a single widget covers both states and
always offers a language switcher.
## One destination, two labels
Every signed-in visitor gets exactly one entry leading to `/admin`, because
`/admin` is the one page core declares unconditionally and admits EVERY
authenticated visitor to (`PhoenixKitWeb.Users.Auth.landing_view?/1` exempts
the index from the admin-area gate, and the page shows a permission-less
visitor the welcome block and nothing else). An admin-area holder sees it as
"Admin Panel" with a shield; everybody else sees "My Account" with a house.
The two are mutually exclusive and share `admin_entry_label/1`.
The URL is always built with `Routes.path("/admin")`, so a host running
`config :phoenix_kit, admin_path: "/myaccount"` gets `/myaccount` here for
free — `/admin` stays the canonical spelling in code.
## Attributes
* `:scope` — current scope; `nil`/unauthenticated renders the guest dropdown.
* `:current_path` — used for active-link highlighting and locale-switch URLs.
Canonicalised before comparison, so a renamed admin segment still
highlights.
* `:current_locale` — the active locale. A full dialect (`"en-US"`) is
accepted: every URL built here reduces it to the base code the router
actually serves (`/en/…`), so passing `@current_locale` rather than
`@current_locale_base` no longer emits a link that costs a redirect.
* `:show_language_switcher` — include the in-menu language list (default `true`).
Set `false` when the host renders a standalone switcher elsewhere to avoid
a duplicate. Applies to both the signed-in and guest states.
* `:guest_links` — which guest links may appear, e.g.
`[:login, :register, :reset, :magic_link]` (default: all). Links are also
gated by the `allow_registration` / `magic_link_login_enabled` settings, so
this list can only narrow, never force-enable a disabled feature.
* `:authenticated_links` — which authenticated-menu entries may appear, e.g.
`[:admin, :dashboard, :settings, :logout]` (default: all). Same narrowing
rule as `:guest_links` — `:admin` still requires `Scope.can_access_admin_area?/1` to be
true, so listing it can't grant an entry a non-admin shouldn't see. Use
this to hide entries a host app's own navigation already covers.
`:dashboard` is the "My Account" half of the pair described above — the
admin-area entry shown to a visitor `:admin` does not cover. It used to
point at the deprecated user dashboard (`/dashboard`,
`PhoenixKit.Install.Deprecations.user_dashboard_warning/0`), which a host
can compile out with `user_dashboard_enabled: false` — leaving this menu
offering a 404. The key name is kept so hosts passing an explicit list
need no edit.
"""
attr(:scope, :any, default: nil)
attr(:current_path, :string, default: "")
attr(:current_locale, :string, default: "en")
attr(:admin_edit_url, :string, default: nil)
attr(:admin_edit_label, :string, default: nil)
attr(:show_language_switcher, :boolean, default: true)
attr(:guest_links, :list, default: [:login, :register, :reset, :magic_link])
attr(:authenticated_links, :list, default: [:admin, :dashboard, :settings, :logout])
def user_dropdown(assigns) do
user = Scope.user(assigns.scope)
multi_session_allowed? = assigns.scope && assigns.scope.multi_session_allowed?
accounts = (assigns.scope && assigns.scope.multi_session_accounts) || []
assigns =
assigns
|> assign(:user, user)
|> assign(:multi_session_allowed?, multi_session_allowed?)
|> assign(:accounts, accounts)
~H"""
<%= if @scope && PhoenixKit.Users.Auth.Scope.authenticated?(@scope) do %>
{PhoenixKit.Users.Auth.Scope.user_email(@scope)}
<%= if :admin in @authenticated_links && PhoenixKit.Users.Auth.Scope.can_access_admin_area?(@scope) do %>
<.language_menu_section
:if={@show_language_switcher}
current_path={@current_path}
current_locale={@current_locale}
/>
<%= if @multi_session_allowed? do %>
<% end %>
<% else %>
<.guest_dropdown
current_path={@current_path}
current_locale={@current_locale}
guest_links={@guest_links}
show_language_switcher={@show_language_switcher}
/>
<% end %>
"""
end
# OAuth buttons for the "Add account" modal.
# Uses the same provider availability checks as the main login page —
# a provider button only appears when it is enabled in General Settings.
# Each link targets /users/auth/:provider with add_account=1 so the OAuth
# callback knows to append the result to the multi-session stack.
# Duplicated from AdminNav's private component of the same name rather
# than shared — keeps this frontend-facing module independent of the
# admin-only one.
attr :current_path, :string, default: "/"
defp add_account_oauth_buttons(assigns) do
google_enabled = OAuthAvailability.provider_enabled?(:google)
github_enabled = OAuthAvailability.provider_enabled?(:github)
facebook_enabled = OAuthAvailability.provider_enabled?(:facebook)
any_enabled = google_enabled or github_enabled or facebook_enabled
assigns =
assigns
|> assign(:google_enabled, google_enabled)
|> assign(:github_enabled, github_enabled)
|> assign(:facebook_enabled, facebook_enabled)
|> assign(:any_enabled, any_enabled)
~H"""
<%= if @any_enabled do %>
{gettext("Or add via")}
<%= if @google_enabled do %>
<.link
href={
Routes.path("/users/auth/google", locale: :none) <>
"?add_account=1&return_to=#{URI.encode_www_form(@current_path)}"
}
class="btn btn-outline w-full flex items-center justify-center gap-2"
>
{gettext("Add Google account")}
<% end %>
<%= if @github_enabled do %>
<.link
href={
Routes.path("/users/auth/github", locale: :none) <>
"?add_account=1&return_to=#{URI.encode_www_form(@current_path)}"
}
class="btn btn-outline w-full flex items-center justify-center gap-2"
>
{gettext("Add GitHub account")}
<% end %>
<%= if @facebook_enabled do %>
<.link
href={
Routes.path("/users/auth/facebook", locale: :none) <>
"?add_account=1&return_to=#{URI.encode_www_form(@current_path)}"
}
class="btn btn-outline w-full flex items-center justify-center gap-2"
>
{gettext("Add Facebook account")}
<% end %>
<% end %>
"""
end
# Guest counterpart to the authenticated avatar dropdown: same shape and
# styling, a generic "not signed in" trigger icon, guest-relevant links,
# and the shared language switcher.
attr(:current_path, :string, required: true)
attr(:current_locale, :string, required: true)
attr(:guest_links, :list, required: true)
attr(:show_language_switcher, :boolean, required: true)
defp guest_dropdown(assigns) do
assigns = assign(assigns, :links, visible_guest_links(assigns.guest_links))
~H"""
<%!--
Rounded-rectangle placeholder matching the authenticated avatar shape
(md = w-10 h-10, rounded-lg via its `!rounded-lg`), so the guest and
signed-in triggers look consistent — a generic person silhouette
signals "not signed in".
--%>
"""
end
# Shared, independently scrollable language list used by both the
# authenticated and guest dropdowns. Renders nothing when fewer than two
# languages are enabled. Anchors are styled directly (no nested daisyUI
# `
`) so the parent menu doesn't apply submenu indent or
# li-child padding that would force horizontal scroll. See admin_nav.ex
# for the matching pattern.
attr(:current_path, :string, required: true)
attr(:current_locale, :string, required: true)
defp language_menu_section(assigns) do
# Highlight by BASE code, not full-dialect equality. `@current_locale`
# may be a base ("en", per the attr doc) or a resolved dialect that
# differs from the enabled one (e.g. `resolve_dialect("en")` => "en-US"
# while English is enabled as "en-GB"), so a raw `==` against the
# enabled dialect never matched on the default locale. Comparing bases
# makes en/en-GB/en-US all match and works whether the caller passes a
# base or a dialect — same rule the standalone `Core.LanguageSwitcher` uses.
current_base = DialectMapper.extract_base(assigns.current_locale)
user_languages =
Enum.map(get_user_languages(), fn language ->
Map.put(language, :active?, DialectMapper.extract_base(language.code) == current_base)
end)
assigns = assign(assigns, :user_languages, user_languages)
~H"""
<%= if length(@user_languages) > 1 do %>
<% end %>
"""
end
# Resolves which guest links to render: starts from the catalog, keeps
# only keys the caller allowed, and applies the per-feature settings
# gates (`allow_registration`, `magic_link_login_enabled`). Log in and
# forgot-password are always available; the others are gated.
defp visible_guest_links(allowed) do
allow_registration? = Settings.get_boolean_setting("allow_registration", true)
magic_link? = Settings.get_boolean_setting("magic_link_login_enabled", true)
@guest_link_catalog
|> Enum.filter(fn {key, _path, _icon} ->
key in allowed and guest_link_enabled?(key, allow_registration?, magic_link?)
end)
|> Enum.map(fn {key, path, icon} ->
%{key: key, path: path, icon: icon, label: guest_link_label(key)}
end)
end
defp guest_link_enabled?(:register, allow_registration?, _magic?), do: allow_registration?
defp guest_link_enabled?(:magic_link, _allow?, magic?), do: magic?
defp guest_link_enabled?(_key, _allow?, _magic?), do: true
defp guest_link_label(:login), do: gettext("Log in")
defp guest_link_label(:register), do: gettext("Sign up")
defp guest_link_label(:reset), do: gettext("Forgot password")
defp guest_link_label(:magic_link), do: gettext("Magic link")
# Helper function to get user languages from Languages module
# Returns enabled languages or falls back to English if module is disabled
defp get_user_languages do
# Get enabled languages from the Languages module
languages =
if Languages.enabled?() do
Languages.get_enabled_languages()
else
# Fallback to English when module is disabled
[%{code: "en-US", name: "English (United States)", is_enabled: true}]
end
# Map to expected format with enriched data from predefined languages.
# The final `dedupe_names/1` call drops the country qualifier when
# only one dialect of a given base language is configured — the
# same rule the frontend switcher applies, called from its
# canonical home in `Core.LanguageSwitcher` so the logic lives in
# one place.
languages
|> Enum.map(fn lang ->
case Languages.get_predefined_language(lang.code) do
%{name: name, flag: flag, native: native} ->
%{code: lang.code, name: name, flag: flag, native: native}
nil ->
%{code: lang.code, name: String.upcase(lang.code), flag: "🌐", native: ""}
end
end)
|> LanguageSwitcher.dedupe_names()
end
# Helper function to get language flag emoji
defp get_language_flag(code) when is_binary(code) do
case Languages.get_predefined_language(code) do
%{flag: flag} -> flag
nil -> "🌐"
end
end
# The label for the entry that leads to the admin area.
#
# ONE source for both call sites above, which are mutually exclusive: an
# admin-area holder gets the `:admin` entry, everyone else gets the
# `:dashboard` one, and both navigate to `Routes.path("/admin")`. The
# wording is the only thing that differs, so it lives here rather than
# being written out twice.
#
# Translated on both arms, not typed into an operator form, for the reason
# `PhoenixKitWeb.Components.LayoutWrapper` gives for the "Admin Panel" chip:
# these are common noun phrases, already translated in every shipped locale,
# and a stored string would serve one language's wording to all of them.
#
# A DEVELOPER can rename the admin arm in `config.exs` — either by picking a
# translated preset (`admin_panel_label: :workspace`) or, since renaming the
# URL usually means renaming the thing, just by setting `admin_path:`, which
# the label derives from. Both go through
# `PhoenixKitWeb.Components.Core.AdminLabel`, the same resolver the header
# chip uses, so the menu entry and the chip always say the same thing.
#
# Neither touches the "My Account" arm, which names the visitor's own
# account rather than the admin area.
defp admin_entry_label(scope) do
if Scope.can_access_admin_area?(scope),
do: AdminLabel.text(),
else: gettext("My Account")
end
# Check if current path matches the given path
defp active_path?(current_path, path) when is_binary(current_path) and is_binary(path) do
# Remove PhoenixKit prefix if present
normalized_path = remove_phoenix_kit_prefix(current_path)
# Remove locale prefix if present
# Then fold the configured admin segment back to canonical `/admin`, so a
# host running `admin_path: "/myaccount"` compares `/myaccount/...` against
# the `/admin` written above rather than never matching (dead highlight).
clean_path =
normalized_path
|> remove_locale_prefix()
|> Routes.canonical_admin_path()
# Check for exact match or ends with path
clean_path == path or String.ends_with?(clean_path, path)
end
defp active_path?(_, _), do: false
# Remove PhoenixKit prefix
defp remove_phoenix_kit_prefix(path) do
url_prefix = PhoenixKit.Config.get_url_prefix()
if url_prefix == "/" do
path
else
String.replace_prefix(path, url_prefix, "")
end
end
# Remove locale prefix
defp remove_locale_prefix(path) do
case String.split(path, "/", parts: 3) do
["", locale, rest] when locale != "" and rest != "" ->
if looks_like_locale?(locale), do: "/" <> rest, else: path
["", locale] ->
if looks_like_locale?(locale), do: "/", else: path
_ ->
path
end
end
# Check if it looks like a locale code
defp looks_like_locale?(locale) do
String.length(locale) <= 8 and String.match?(locale, ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/)
end
end