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.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,
Admin/Dashboard/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.
## Attributes
* `:scope` — current scope; `nil`/unauthenticated renders the guest dropdown.
* `:current_path` — used for active-link highlighting and locale-switch URLs.
* `:current_locale` — base code of the active locale (highlighted in the list).
* `: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.admin?/1` to be
true, so listing it can't grant an entry a non-admin shouldn't see. Use
this to hide entries (e.g. `:dashboard`) a host app's own navigation
already covers.
"""
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.admin?(@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
# 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
clean_path = remove_locale_prefix(normalized_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
# Legacy helper - kept for backward compatibility
defp generate_language_switch_url(current_path, new_locale) do
base_code = DialectMapper.extract_base(new_locale)
# Extract the path without locale and regenerate with new locale
url_prefix = PhoenixKit.Config.get_url_prefix()
base_prefix = if url_prefix === "/", do: "", else: url_prefix
# Remove prefix and locale from current_path
clean_path =
current_path
|> String.replace_prefix(base_prefix, "")
|> remove_locale_from_path()
# Generate new path with the new locale
Routes.path(clean_path, locale: base_code)
end
# Remove locale from path.
#
# A path that is *only* a locale segment (e.g. "/ru", "/en-GB") reduces
# to "/". The empty-`rest` guard matters: `Path.join([])` raises a
# FunctionClauseError, which used to 500 a host's bare `/:locale` landing
# route (e.g. a locale-prefixed landing page introduced in 1.7.150+).
defp remove_locale_from_path(path) do
case String.split(path, "/", trim: true) do
[segment | rest] when byte_size(segment) in [2, 5] ->
cond do
not locale_segment?(segment) -> path
rest == [] -> "/"
true -> "/" <> Path.join(rest)
end
_ ->
path
end
end
# A 2-char base code or a 5-char dialect ("en-GB"). Mirrors the original
# inline guard; kept narrow on purpose so a real 3-char page segment
# (e.g. "/faq") isn't mistaken for a locale.
defp locale_segment?(segment) do
String.length(segment) == 2 or
(String.length(segment) == 5 and String.contains?(segment, "-"))
end
end