defmodule PhoenixKitWeb.Users.Auth do @compile {:no_warn_undefined, [PhoenixKitEcommerce, PhoenixKitWeb.Live.Modules.Legal.Settings]} @moduledoc """ Authentication and authorization plugs for PhoenixKit user management. This module provides plugs and functions for handling user authentication, session management, and access control in Phoenix applications using PhoenixKit. ## Key Features - User authentication with email and password - Remember me functionality with secure cookies - Session-based authentication - Route protection and access control - Module-level permission enforcement via on_mount hooks - Integration with Phoenix LiveView on_mount callbacks ## on_mount Hooks - `:phoenix_kit_ensure_admin` — Requires Owner/Admin role, or a custom role with at least one permission. Every role is then checked against the permission key mapped to the current admin view: Owner passes because its scope holds every key by construction; Admin holds keys as real, Owner-revocable rows (seeded/auto-granted by default). Disabled modules block everyone. Views that resolve to no permission key allow only a scope holding every enabled permission — role-agnostic, so a named Admin whose keys an Owner has partially revoked is denied too. The first mount of such a view logs a warning, whoever mounts it, so the missing mapping surfaces before a colleague hits the 403. - `:phoenix_kit_ensure_module_access` — Checks that the feature module is permitted for the scope and (for custom roles) enabled. ## Usage The plugs in this module are automatically configured when using `PhoenixKitWeb.Integration.phoenix_kit_routes/0` macro in your router. """ use PhoenixKitWeb, :verified_routes import Plug.Conn import Phoenix.Controller import Phoenix.LiveView, only: [attach_hook: 4] require Logger alias Phoenix.LiveView alias PhoenixKit.Admin.Events alias PhoenixKit.ModuleRegistry alias PhoenixKit.Modules.Languages alias PhoenixKit.Modules.Languages.DialectMapper alias PhoenixKit.Modules.Maintenance alias PhoenixKit.Modules.SEO alias PhoenixKit.Users.Auth alias PhoenixKit.Users.Auth.{Scope, User} alias PhoenixKit.Users.LoginAlerts alias PhoenixKit.Users.Permissions alias PhoenixKit.Users.Referrals alias PhoenixKit.Users.ScopeNotifier alias PhoenixKit.Utils.Routes alias PhoenixKit.Utils.SessionFingerprint alias PhoenixKit.Utils.UserAgent alias PhoenixKitWeb.Users.MultiSession # Make the remember me cookie valid for 60 days. # If you want bump or reduce this value, also change # the token expiry itself in UserToken. @max_age 60 * 60 * 24 * 60 @remember_me_cookie "_phoenix_kit_web_user_remember_me" @remember_me_options [ sign: true, max_age: @max_age, same_site: "Lax", http_only: true, secure: true ] @doc """ Logs the user in. It renews the session ID and clears the whole session to avoid fixation attacks. See the renew_session function to customize this behaviour. It also sets a `:live_socket_id` key in the session, so LiveView sessions are identified and automatically disconnected on log out. The line can be safely removed if you are not using LiveView. ## Session Fingerprinting When session fingerprinting is enabled, this function captures the user's IP address and user agent to create a session fingerprint. This helps detect session hijacking attempts. """ def log_in_user(conn, user, params \\ %{}) do # Create session fingerprint if enabled opts = if SessionFingerprint.fingerprinting_enabled?() do fingerprint = SessionFingerprint.create_fingerprint(conn) [fingerprint: fingerprint] else [] end # Readable device name for the sessions UI, captured at login independent # of fingerprinting (which keeps only the hashed UA) and of the alerts # setting. The raw UA isn't stored — only the parsed browser/OS. ua = List.first(get_req_header(conn, "user-agent")) opts = Keyword.merge(opts, browser: UserAgent.browser(ua), os: UserAgent.os(ua)) token = Auth.generate_user_session_token(user, opts) # Destination: an explicit `return_to` in params wins, then whatever a gate # stashed in the session, then the `after_login_path` setting. Honoring the # param matters because callers pass it (the OAuth callback has always # passed `"return_to"` here, and it was silently dropped — session renewal # below then wiped the session copy too, so an OAuth login started from a # protected page landed on the default). # # Resolved by `post_auth_path/1` rather than a local guard so this shares # ONE rule with the confirmation pages: local-path only AND never a # sign-in page. A bare `local_path?` check let `?return_to=/users/log-out` # through — a real GET route, so the user was signed back out the instant # they signed in. user_return_to = Routes.post_auth_path([params["return_to"], get_session(conn, :user_return_to)]) # Merge guest cart into user cart before session renewal clears session data. # The shop_session_id cookie survives renew_session (only session data is cleared). maybe_merge_guest_cart(conn, user) # New-device login alert. Every login path (password, magic link, # OAuth, QR) funnels through here, so this is the single integration # point. No-ops when new_login_alert_enabled is off; never raises. LoginAlerts.check(user, conn) conn |> renew_session() |> put_token_in_session(token) |> maybe_write_remember_me_cookie(token, params) |> redirect(to: user_return_to) end defp maybe_merge_guest_cart(conn, user) do if Code.ensure_loaded?(PhoenixKitEcommerce) do # Session FIRST, cookie only as a fallback. # # The shop session cookie is signed, so `conn.cookies["shop_session_id"]` # here is the signed envelope ("SFMyNTY...."), not the id — `:browser` # fetches cookies unsigned, and only `fetch_cookies(conn, signed: [...])` # inside the shop plug verifies it. The envelope is truthy, so reading # the cookie first meant `||` never fell through to the session, which # is the one place the real id lives. `merge_guest_cart(, user)` # then matched no cart and returned quietly: every guest who filled a # cart and logged in silently lost it. # # The shop plug mirrors the resolved id into the session on every # request, so the session is both authoritative and already verified. # The cookie fallback stays for a host that wires the routes without # the shop plug. shop_session_id = get_session(conn, :shop_session_id) || conn.cookies["shop_session_id"] if shop_session_id do try do # credo:disable-for-next-line Credo.Check.Design.AliasUsage PhoenixKitEcommerce.merge_guest_cart(shop_session_id, user) rescue _ -> :ok end end end conn end # The site-wide `remember_me_enabled` policy is enforced HERE rather than at # each caller, so it holds by construction: with it off, no flow — not a # forged param, not OAuth, not a future login path — can leave a persistent # cookie. defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do if remember_me_enabled?() do put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) else conn end end # Not a remembered login — clear any cookie this browser still holds. Logging # in mints a new token and (on a password change) deletes the old ones, so a # carried-over cookie would point at a dead token: it survives until the # browser drops its session cookie, then silently signs the user out and # keeps failing for the cookie's full 60 days. defp maybe_write_remember_me_cookie(conn, _token, _params) do delete_resp_cookie(conn, @remember_me_cookie) end @doc """ Whether the site allows persistent ("remember me") sessions at all. Site-wide policy via the `remember_me_enabled` setting (default `true`). When false the checkbox is hidden on every auth form and no flow can write the persistent cookie — logins last only as long as the browser session. """ @spec remember_me_enabled?() :: boolean() def remember_me_enabled? do PhoenixKit.Settings.get_boolean_setting("remember_me_enabled", true) end @doc """ Whether signing in with a magic link is available. Gates the request page, and the token endpoint that emailed links point at. Turning it off is a security-posture decision ("password and 2FA only"), so it has to close the route rather than only hide the button — an admin who switches it off on the Authorization settings page will reasonably believe nobody can still sign in this way. In-flight links stop working too. Disabling passwordless login means no more magic-link sign-ins, not no new ones from today; the tokens are short-lived, so the window this affects is small. """ @spec magic_link_login_enabled?() :: boolean() def magic_link_login_enabled? do PhoenixKit.Settings.get_boolean_setting("magic_link_login_enabled", true) end @doc """ Whether registering via a magic link is available. Gates the request page and the completion page, for the same reason as `magic_link_login_enabled?/0`: the setting has to close the route, not just hide the entry point. """ @spec magic_link_registration_enabled?() :: boolean() def magic_link_registration_enabled? do PhoenixKit.Settings.get_boolean_setting("magic_link_registration_enabled", true) end @doc """ Whether the "remember me" checkbox starts checked. Site-wide default via the `remember_me_default` setting (default `true`) — users can still untick it per login. Flows with no UI to tick (magic-link login, OAuth) follow this value directly. Always false when `remember_me_enabled?/0` is false. """ @spec remember_me_default?() :: boolean() def remember_me_default? do remember_me_enabled?() and PhoenixKit.Settings.get_boolean_setting("remember_me_default", true) end @doc """ The params a no-UI login flow (magic link, OAuth) should pass to `log_in_user/3` to follow the site's persistence policy. """ @spec remember_me_params() :: map() def remember_me_params do if remember_me_default?(), do: %{"remember_me" => "true"}, else: %{} end @doc """ Whether this request already carries a persistent remember-me cookie. Lets a re-login flow that has no checkbox of its own (the password-change handoff) preserve the choice the user already made, instead of silently downgrading them to a session-only login. """ @spec remembered?(Plug.Conn.t()) :: boolean() def remembered?(conn) do conn = fetch_cookies(conn, signed: [@remember_me_cookie]) is_binary(conn.cookies[@remember_me_cookie]) end # This function renews the session ID and erases the whole # session to avoid fixation attacks. Locale is no longer kept in # session (URL is authoritative; logged-in users persist preference # on `user.custom_fields["preferred_locale"]`), so there's nothing # to preserve across renewal. defp renew_session(conn) do delete_csrf_token() conn |> configure_session(renew: true) |> clear_session() end @doc """ Logs the user out. It clears all session data for safety. See renew_session. """ def log_out_user(conn) do user_token = get_session(conn, :user_token) # Get user info before deleting any token, for the admin notification below. user = user_token && Auth.get_user_by_session_token(user_token) # Invalidate the active token AND every secondary multi-session account in # the stack (stack_tokens/1 falls back to just the active token when no # stack is set, so this covers the ordinary single-session logout too). # Centralising the drain here means every logout path is covered by # construction — and it runs AFTER the user lookup above so the # session-disconnect broadcast can still resolve the user. MultiSession.delete_all_stack_tokens(conn) if live_socket_id = get_session(conn, :live_socket_id) do broadcast_disconnect(live_socket_id) end # Notify admin panel about user logout if user do session_id = extract_session_id_from_live_socket_id(get_session(conn, :live_socket_id)) Events.broadcast_user_session_disconnected(user.uuid, session_id) end conn |> renew_session() |> delete_resp_cookie(@remember_me_cookie) |> redirect(to: "/") end @doc """ Logs out a specific user by invalidating all their session tokens and broadcasting disconnect to their LiveView sessions. This function is useful when user roles or permissions change and you need to force re-authentication to ensure the user gets updated permissions in their session. ## Parameters - `user`: The user to log out from all sessions ## Examples iex> log_out_user_from_all_sessions(user) :ok """ def log_out_user_from_all_sessions(user) do # Get all session tokens before deleting them user_tokens = Auth.get_all_user_session_tokens(user) # Broadcast disconnect to all LiveView sessions for this user # Each session token creates a unique live_socket_id Enum.each(user_tokens, fn token -> live_socket_id = "phoenix_kit_sessions:#{Base.url_encode64(token.token)}" broadcast_disconnect(live_socket_id) end) # Delete all session tokens for this user Auth.delete_all_user_session_tokens(user) :ok end @doc """ Authenticates the user by looking into the session and remember me token. Also verifies session fingerprints if enabled to detect session hijacking attempts. This plug is idempotent - if the user has already been fetched, it returns early to avoid duplicate database queries. """ def fetch_phoenix_kit_current_user(conn, _opts) do # Early return if user already fetched (idempotent) if Map.has_key?(conn.assigns, :phoenix_kit_current_user) do conn else do_fetch_phoenix_kit_current_user(conn) end end defp do_fetch_phoenix_kit_current_user(conn) do {user_token, conn} = ensure_user_token(conn) # Verify session fingerprint if token exists fingerprint_valid? = if user_token do case Auth.verify_session_fingerprint(conn, user_token) do :ok -> true {:warning, reason} -> # Log warning but allow access (IP/UA can legitimately change) Logger.warning("PhoenixKit: Session fingerprint warning: #{reason} for token") # In non-strict mode, allow access despite warning not SessionFingerprint.strict_mode?() {:error, :fingerprint_mismatch} -> # Both IP and UA changed - likely hijacking Logger.error( "PhoenixKit: Session fingerprint mismatch detected - possible hijacking attempt" ) # Strict mode: deny access; non-strict: log but allow not SessionFingerprint.strict_mode?() {:error, :token_not_found} -> # Token expired or invalid false end else true end user = if fingerprint_valid? do user_token && Auth.get_user_by_session_token(user_token) else # Fingerprint verification failed in strict mode nil end # Check if user is active using centralized function active_user = Auth.ensure_active_user(user) assign(conn, :phoenix_kit_current_user, active_user) end @doc """ Fetches the current user and creates a scope for authentication context. This plug combines user fetching with scope creation, providing a structured way to handle authentication state in your application. The scope is assigned to `:phoenix_kit_current_scope` and includes both the user and authentication status. Also verifies session fingerprints if enabled to detect session hijacking attempts. """ def fetch_phoenix_kit_current_scope(conn, _opts) do {user_token, conn} = ensure_user_token(conn) # Verify session fingerprint if token exists fingerprint_valid? = if user_token do case Auth.verify_session_fingerprint(conn, user_token) do :ok -> true {:warning, reason} -> # Log warning but allow access (IP/UA can legitimately change) Logger.warning("PhoenixKit: Session fingerprint warning: #{reason} for token (scope)") # In non-strict mode, allow access despite warning not SessionFingerprint.strict_mode?() {:error, :fingerprint_mismatch} -> # Both IP and UA changed - likely hijacking Logger.error( "PhoenixKit: Session fingerprint mismatch detected in scope - possible hijacking" ) # Strict mode: deny access; non-strict: log but allow not SessionFingerprint.strict_mode?() {:error, :token_not_found} -> # Token expired or invalid false end else true end user = if fingerprint_valid? do user_token && Auth.get_user_by_session_token(user_token) else # Fingerprint verification failed in strict mode nil end # Check if user is active using centralized function active_user = Auth.ensure_active_user(user) session = get_session(conn) {multi_session_allowed?, multi_session_accounts} = MultiSession.scope_fields(session) scope = %{ Scope.for_user(active_user) | multi_session_accounts: multi_session_accounts, multi_session_allowed?: multi_session_allowed? } conn |> assign(:phoenix_kit_current_user, active_user) |> assign(:phoenix_kit_current_scope, scope) end defp ensure_user_token(conn) do if token = get_session(conn, :user_token) do {token, conn} else conn = fetch_cookies(conn, signed: [@remember_me_cookie]) case conn.cookies[@remember_me_cookie] do token when is_binary(token) -> # Honor the master switch on READ too, not just on write. Blocking new # cookies while still accepting old ones would let every cookie issued # before the switch was turned off keep restoring sessions for its # full 60 days — the opposite of what turning it off means. if remember_me_enabled?() do {token, put_token_in_session(conn, token)} else {nil, delete_resp_cookie(conn, @remember_me_cookie)} end _ -> {nil, conn} end end end @doc """ Checks if the current user is authenticated and returns a redirect socket if so. Used by auth pages (login, register, etc.) that should not be accessible to already-authenticated users when placed in a shared public live_session. Returns `{:redirect, redirected_socket}` if authenticated (caller should halt), or `:cont` if not authenticated (caller should proceed with normal mount). """ def maybe_redirect_authenticated(socket) do if Scope.authenticated?(socket.assigns[:phoenix_kit_current_scope]) do {:redirect, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} else :cont end end @doc """ Handles mounting and authenticating the phoenix_kit_current_user in LiveViews. ## `on_mount` arguments * `:phoenix_kit_mount_current_user` - Assigns phoenix_kit_current_user to socket assigns based on user_token, or nil if there's no user_token or no matching user. * `:phoenix_kit_mount_current_scope` - Assigns both phoenix_kit_current_user and phoenix_kit_current_scope to socket assigns. The scope provides structured access to authentication state. * `:phoenix_kit_ensure_authenticated` - Authenticates the user from the session, and assigns the phoenix_kit_current_user to socket assigns based on user_token. Redirects to login page if there's no logged user. * `:phoenix_kit_ensure_authenticated_scope` - Authenticates the user via scope system, assigns both phoenix_kit_current_user and phoenix_kit_current_scope. * `:phoenix_kit_ensure_owner` - Ensures the user has owner role, and redirects to the home page if not. * `:phoenix_kit_ensure_admin` - Ensures the user has admin or owner role, and redirects to the home page if not. Redirects to login page if there's no logged user. * `:phoenix_kit_redirect_if_user_is_authenticated` - Authenticates the user from the session. Redirects to signed_in_path if there's a logged user. * `:phoenix_kit_redirect_if_authenticated_scope` - Checks authentication via scope system. Redirects to signed_in_path if there's a logged user. ## Examples Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate the current_user: defmodule PhoenixKitWeb.PageLive do use PhoenixKitWeb, :live_view on_mount {PhoenixKitWeb.Users.Auth, :phoenix_kit_mount_current_user} ... end Or use the scope system for better encapsulation: defmodule PhoenixKitWeb.PageLive do use PhoenixKitWeb, :live_view on_mount {PhoenixKitWeb.Users.Auth, :phoenix_kit_mount_current_scope} ... end Or use the `live_session` of your router to invoke the on_mount callback: live_session :authenticated, on_mount: [{PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_authenticated_scope}] do live "/profile", ProfileLive, :index end """ def on_mount(:phoenix_kit_mount_current_user, _params, session, socket) do {:cont, mount_phoenix_kit_current_user(socket, session)} end def on_mount(:phoenix_kit_mount_current_scope, params, session, socket) do socket = mount_phoenix_kit_current_scope(socket, session, params) socket = attach_locale_hook(socket) {:cont, socket} end def on_mount(:phoenix_kit_ensure_authenticated, _params, session, socket) do socket = mount_phoenix_kit_current_user(socket, session) user = socket.assigns.phoenix_kit_current_user if is_nil(user) do {:halt, redirect_to_login(socket)} else live_account_gate(socket, user) end end def on_mount(:phoenix_kit_ensure_authenticated_scope, params, session, socket) do socket = mount_phoenix_kit_current_scope(socket, session, params) socket = attach_locale_hook(socket) scope = socket.assigns.phoenix_kit_current_scope with {:cont, socket} <- require_authenticated_live(socket, scope) do live_account_gate(socket, scope) end end def on_mount(:phoenix_kit_redirect_if_user_is_authenticated, _params, session, socket) do socket = mount_phoenix_kit_current_user(socket, session) if socket.assigns.phoenix_kit_current_user do {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} else {:cont, socket} end end def on_mount(:phoenix_kit_redirect_if_authenticated_scope, _params, session, socket) do socket = mount_phoenix_kit_current_scope(socket, session) if Scope.authenticated?(socket.assigns.phoenix_kit_current_scope) do {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} else socket = attach_locale_hook(socket) {:cont, socket} end end def on_mount(:phoenix_kit_ensure_owner, _params, session, socket) do socket = mount_phoenix_kit_current_scope(socket, session) scope = socket.assigns.phoenix_kit_current_scope with {:cont, socket} <- require_authenticated_live(socket, scope), {:cont, socket} <- live_account_gate(socket, scope) do if Scope.owner?(scope) do {:cont, attach_locale_hook(socket)} else socket = socket |> Phoenix.LiveView.put_flash(:error, "You must be an owner to access this page.") |> Phoenix.LiveView.redirect(to: "/") {:halt, socket} end end end def on_mount(:phoenix_kit_ensure_admin, params, session, socket) do socket = mount_phoenix_kit_current_scope(socket, session, params) scope = socket.assigns.phoenix_kit_current_scope with {:cont, socket} <- require_authenticated_live(socket, scope), {:cont, socket} <- live_account_gate(socket, scope) do if Scope.can_access_admin_area?(scope) do socket = attach_locale_hook(socket) socket = maybe_subscribe_to_module_events(socket) socket = maybe_apply_plugin_layout(socket) enforce_admin_view_permission(socket, scope) else socket = socket |> Phoenix.LiveView.put_flash( :error, "You do not have the required permission to access this page." ) |> Phoenix.LiveView.redirect(to: "/") {:halt, socket} end end end def on_mount({:phoenix_kit_ensure_module_access, module_key}, params, session, socket) do socket = mount_phoenix_kit_current_scope(socket, session, params) scope = socket.assigns.phoenix_kit_current_scope # Store current module key for scope refresh checks socket = Phoenix.Component.assign(socket, :phoenix_kit_current_module_key, module_key) with {:cont, socket} <- require_authenticated_live(socket, scope), {:cont, socket} <- live_account_gate(socket, scope) do cond do not Scope.can_access_admin_area?(scope) -> socket = socket |> Phoenix.LiveView.put_flash( :error, "You do not have the required permission to access this page." ) |> Phoenix.LiveView.redirect(to: "/") {:halt, socket} # A disabled module's admin surface is blocked for EVERYONE, Owner # included — matching `enforce_admin_view_permission/2`. (Previously Owner # bypassed enablement here but not on fresh LV mounts, so the same user # saw a disabled module's page through one hook and a redirect through # another. Enablement is now uniform across all three gates.) Scope.has_module_access?(scope, module_key) and MapSet.member?(Permissions.enabled_module_keys(), module_key) -> socket = attach_locale_hook(socket) {:cont, socket} true -> redirect_to = best_available_admin_path(scope) socket = socket |> Phoenix.LiveView.put_flash( :error, "You do not have permission to access this section." ) |> Phoenix.LiveView.redirect(to: redirect_to) {:halt, socket} end end end # Shared first step for every `ensure_*` scope hook: `{:cont, socket}` for an # authenticated visitor, otherwise a halt to the login page carrying the # originally requested path. defp require_authenticated_live(socket, scope) do if Scope.authenticated?(scope) do {:cont, socket} else {:halt, redirect_to_login(socket)} end end defp redirect_to_login(socket) do socket |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") |> Phoenix.LiveView.redirect(to: login_path_with_return_to(socket)) end # LiveView side of `account_gate/1`. The account is authenticated; this is # everything it must additionally satisfy before it may use the application. defp live_account_gate(socket, subject) do case account_gate(subject) do :ok -> {:cont, socket} {:halt, message, target} -> socket = socket |> Phoenix.LiveView.put_flash(:error, message) |> Phoenix.LiveView.redirect(to: path_with_return_to(socket, target)) {:halt, socket} end end # Attach a hook to handle locale switching events from language switcher defp attach_locale_hook(socket) do # Check if hook is already attached to avoid duplicates if socket.assigns[:phoenix_kit_locale_hook_attached?] do socket else socket |> Phoenix.Component.assign(:phoenix_kit_locale_hook_attached?, true) |> Phoenix.LiveView.attach_hook( :phoenix_kit_locale_handler, :handle_event, &handle_locale_event/3 ) end end defp handle_locale_event("phoenix_kit_set_locale", %{"locale" => locale, "url" => url}, socket) do save_user_locale_preference(socket.assigns, locale) # `push_navigate` rather than `redirect`: admin's two URL shapes # (`/admin/*` and `/:locale/admin/*`) now share one `live_session` # (`:phoenix_kit_admin`), so an in-admin locale switch stays on the # WebSocket. For targets in a different live_session (e.g. front-end # pages, whose public sessions are still split) `push_navigate` # degrades to a full-page load on its own — so it is safe here # unconditionally. {:halt, Phoenix.LiveView.push_navigate(socket, to: url)} end defp handle_locale_event(_event, _params, socket), do: {:cont, socket} defp save_user_locale_preference(%{phoenix_kit_current_user: %{} = user}, locale) when not is_nil(user) do Auth.update_user_locale_preference(user, locale) end defp save_user_locale_preference(%{phoenix_kit_current_scope: scope}, locale) do case Scope.user(scope) do %{} = user -> Auth.update_user_locale_preference(user, locale) _ -> :ok end end defp save_user_locale_preference(_assigns, _locale), do: :ok defp set_routing_info(params, url, socket) do %{path: path} = URI.parse(url) socket = socket |> Phoenix.Component.assign(:url_path, path) |> maybe_update_locale_from_params(params) # This hook fires on `handle_params` for every LiveView mounted through # PhoenixKit's on_mount chain (admin and host-app public pages alike), # so it is the one place that can guarantee `:seo_no_index` reaches # root.html.heex's noindex meta tag before the first render — unlike # LayoutWrapper.app_layout_inner/1, which only wraps admin/plugin views # and never runs for a host app's own public LiveViews. assign_new is # a no-op if LayoutWrapper already set it, so this is safe either way. |> Phoenix.Component.assign_new(:seo_no_index, fn -> SEO.no_index_enabled?() end) {:cont, socket} end # Update locale assigns when navigating to a URL with a locale param defp maybe_update_locale_from_params(socket, %{"locale" => locale}) when is_binary(locale) do # Only update if locale actually changed current_base = socket.assigns[:current_locale_base] if current_base != locale and DialectMapper.valid_base_code?(locale) do # URL-driven dialect: `DialectMapper.resolve_dialect/1` maps the base # code straight to its default dialect. It deliberately takes no user # — a user's `preferred_locale` would otherwise upgrade base "en" → # "en-GB", contradicting the URL-is-authoritative semantic we now hold # across both the LV mount and the HTTP plug. `preferred_locale` is # still written by the switcher hook but no longer read for routing # (base or dialect). full_dialect = DialectMapper.resolve_dialect(locale) # Update Gettext locale — set both the backend-specific value (for # PhoenixKitWeb.Gettext callers that look it up explicitly) and the # process-global default (so feature modules with their own backends, # e.g. PhoenixKitProjects.Gettext, also pick up the new locale without # needing per-backend wiring). Gettext.put_locale(PhoenixKitWeb.Gettext, full_dialect) Gettext.put_locale(full_dialect) socket |> Phoenix.Component.assign(:current_locale_base, locale) |> Phoenix.Component.assign(:current_locale, full_dialect) else socket end end # No locale in URL = primary language. Snap Gettext + assigns to default. # # Pre-fix this branch special-cased reserved paths (`/admin`, `/api`, …) # to "preserve session locale" — back when admin URLs always carried a # locale prefix, the reserved-path snap was unreachable. Now that # `Routes.admin_path/2` emits prefixless URLs for the primary language, # the reserved-path branch was active and pinned a stale session locale # to every prefixless admin navigation (sticky-Estonian bug). Removing # the special case restores the URL-is-authoritative semantics. defp maybe_update_locale_from_params(socket, _params) do default_base = Routes.get_default_admin_locale() current_base = socket.assigns[:current_locale_base] if current_base == default_base do socket else # URL-driven dialect (see sibling clause above for the rationale — # `preferred_locale` is intentionally ignored for routing). default_dialect = DialectMapper.resolve_dialect(default_base) Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) Gettext.put_locale(default_dialect) socket |> Phoenix.Component.assign(:current_locale_base, default_base) |> Phoenix.Component.assign(:current_locale, default_dialect) end end defp mount_phoenix_kit_current_user(socket, session) do socket = attach_hook( socket, :current_page, :handle_params, &set_routing_info(&1, &2, &3) ) Phoenix.Component.assign_new(socket, :phoenix_kit_current_user, fn -> case session["user_token"] do nil -> nil user_token -> get_active_user_from_token(user_token) end end) end defp get_active_user_from_token(user_token) do user = Auth.get_user_by_session_token(user_token) Auth.ensure_active_user(user) end @doc """ Reconstructs and assigns the current user + scope on an **embedded** LiveView mount from a host-supplied `session["current_user_uuid"]`. A LiveView rendered via `live_render/3` mounts with `:not_mounted_at_router`, so it never runs a router `live_session`'s `on_mount` hook (e.g. `:phoenix_kit_ensure_admin`) — leaving `:phoenix_kit_current_scope` / `:phoenix_kit_current_user` absent, which blinds any user-aware embedded UI (comment composers, activity-actor attribution). The host bridges identity across the `live_render` process boundary by passing its own authenticated user's UUID as `session["current_user_uuid"]` — a **string**, never the `%User{}` struct (a signed-but-not-encrypted `live_render` session would expose it to the client). This helper reloads that user and assigns both `:phoenix_kit_current_user` and `:phoenix_kit_current_scope`. * No-op when `:phoenix_kit_current_scope` is already assigned (router mount — the `on_mount` hook ran, before `mount/3`). Never clobbers the canonical scope. * An active user → assigns it + `Scope.for_user(user)`. * Absent / unknown / inactive uuid, or a transient DB error → anonymous (`nil` user + `Scope.for_user(nil)`), never raising. > This reconstructs **identity** (audit, comment authorship), not > **authorization** — it performs no role check. The UUID must come > from the host's trusted server-side scope, never request params; and > a host embedding an admin LiveView must gate the embedding page > itself (the `on_mount` admin gate does not run for embeds). Generic across embeddable feature modules — `phoenix_kit_projects` is the reference consumer. """ @spec assign_embedded_current_user(LiveView.Socket.t(), map()) :: LiveView.Socket.t() def assign_embedded_current_user(socket, session) when is_map(session) do if is_nil(socket.assigns[:phoenix_kit_current_scope]) do {user, scope} = reconstruct_embedded_identity(Map.get(session, "current_user_uuid")) socket |> Phoenix.Component.assign(:phoenix_kit_current_user, user) |> Phoenix.Component.assign(:phoenix_kit_current_scope, scope) else socket end end def assign_embedded_current_user(socket, _session), do: socket defp reconstruct_embedded_identity(uuid) when is_binary(uuid) and uuid != "" do user = uuid |> Auth.get_user() |> Auth.ensure_active_user() if is_nil(user) do Logger.warning( "[phoenix_kit] embedded current_user_uuid=#{inspect(uuid)} did not resolve " <> "to an active user — embedded UI degrades to anonymous" ) end {user, Scope.for_user(user)} rescue e in [Postgrex.Error, DBConnection.ConnectionError, Ecto.QueryError] -> Logger.warning( "[phoenix_kit] failed to load embedded user #{inspect(uuid)}: " <> Exception.message(e) <> " — degrading to anonymous" ) {nil, Scope.for_user(nil)} end defp reconstruct_embedded_identity(_), do: {nil, Scope.for_user(nil)} defp mount_phoenix_kit_current_scope(socket, session, params \\ %{}) do socket = socket |> mount_phoenix_kit_current_user(session) |> maybe_attach_scope_refresh_hook() user = socket.assigns.phoenix_kit_current_user {multi_session_allowed?, multi_session_accounts} = MultiSession.scope_fields(session) scope = %{ Scope.for_user(user) | multi_session_accounts: multi_session_accounts, multi_session_allowed?: multi_session_allowed? } # Locale is URL-driven: the URL's `:locale` segment wins; absent # that, we fall straight to the default. The previous session-locale # fallback ("remember the last-picked locale across prefixless URLs") # caused a sticky-locale bug after `Routes.path/2` was changed to # emit prefixless URLs for the primary language — e.g. visiting # `/foo/et/...` once stashed `"et"` in the session, then every # primary-prefixless URL inherited Estonian forever. Pure URL→default # makes prefixless URL ≡ primary language, which matches what the # URL helpers now emit. current_locale_base = case params do %{"locale" => locale} when is_binary(locale) and locale != "" -> if DialectMapper.valid_base_code?(locale), do: locale, else: nil _ -> nil end || Routes.get_default_admin_locale() # URL-driven dialect: `resolve_dialect/1` returns the default mapping # for this base. The user's preferred_locale is intentionally NOT # consulted for routing (see `maybe_update_locale_from_params/2` for # the matching rationale). current_locale = DialectMapper.resolve_dialect(current_locale_base) # Set Gettext locale for translations (backend-specific + global, so # module backends like PhoenixKitProjects.Gettext sync too). Gettext.put_locale(PhoenixKitWeb.Gettext, current_locale) Gettext.put_locale(current_locale) socket |> maybe_manage_scope_subscription(user) |> Phoenix.Component.assign(:phoenix_kit_current_scope, scope) |> Phoenix.Component.assign(:current_locale, current_locale) |> Phoenix.Component.assign(:current_locale_base, current_locale_base) # Fold the maintenance check into the shared scope mount so any new # live_session that uses a scope-mounting hook can't forget it. |> check_maintenance_mode() end defp maybe_attach_scope_refresh_hook( %{assigns: %{phoenix_kit_scope_hook_attached?: true}} = socket ), do: socket defp maybe_attach_scope_refresh_hook(socket) do socket |> attach_hook(:phoenix_kit_scope_refresh, :handle_info, &handle_scope_refresh/2) |> Phoenix.Component.assign(:phoenix_kit_scope_hook_attached?, true) end defp maybe_manage_scope_subscription(socket, %User{uuid: user_uuid}) when is_binary(user_uuid) do case socket.assigns[:phoenix_kit_scope_subscription_user_uuid] do ^user_uuid -> socket previous_uuid when is_binary(previous_uuid) -> ScopeNotifier.unsubscribe(previous_uuid) ScopeNotifier.subscribe(user_uuid) Phoenix.Component.assign(socket, :phoenix_kit_scope_subscription_user_uuid, user_uuid) _ -> ScopeNotifier.subscribe(user_uuid) Phoenix.Component.assign(socket, :phoenix_kit_scope_subscription_user_uuid, user_uuid) end end defp maybe_manage_scope_subscription(socket, _user) do maybe_unsubscribe_scope_updates(socket) end defp maybe_unsubscribe_scope_updates(socket) do if previous_uuid = socket.assigns[:phoenix_kit_scope_subscription_user_uuid] do ScopeNotifier.unsubscribe(previous_uuid) end Phoenix.Component.assign(socket, :phoenix_kit_scope_subscription_user_uuid, nil) end defp handle_scope_refresh({:phoenix_kit_scope_roles_updated, user_uuid}, socket) do current_scope = socket.assigns[:phoenix_kit_current_scope] if Scope.user_uuid(current_scope) == user_uuid do was_admin = Scope.can_access_admin_area?(current_scope) {socket, new_scope} = refresh_scope_assigns(socket) socket = cond do # Lost admin role entirely was_admin and not Scope.can_access_admin_area?(new_scope) -> socket |> LiveView.put_flash(:error, "You must be an admin to access this page.") |> LiveView.push_navigate(to: "/") # Still admin but lost access to current module Scope.can_access_admin_area?(new_scope) and not has_current_module_access?(socket, new_scope) -> redirect_to = best_available_admin_path(new_scope) socket |> LiveView.put_flash( :error, "You no longer have permission to access this section." ) |> LiveView.push_navigate(to: redirect_to) true -> socket end {:halt, socket} else {:cont, socket} end end defp handle_scope_refresh(_msg, socket), do: {:cont, socket} # Subscribe to module enable/disable events so the admin sidebar updates # in real-time when modules are toggled. Follows the scope refresh pattern. defp maybe_subscribe_to_module_events( %{assigns: %{phoenix_kit_module_hook_attached?: true}} = socket ), do: socket defp maybe_subscribe_to_module_events(socket) do Events.subscribe_to_modules() socket |> attach_hook(:phoenix_kit_module_refresh, :handle_info, &handle_module_refresh/2) |> Phoenix.Component.assign(:phoenix_kit_module_hook_attached?, true) end defp handle_module_refresh({:module_enabled, _key}, socket) do {:halt, Phoenix.Component.assign(socket, :phoenix_kit_modules_version, System.unique_integer())} end defp handle_module_refresh({:module_disabled, _key}, socket) do {:halt, Phoenix.Component.assign(socket, :phoenix_kit_modules_version, System.unique_integer())} end defp handle_module_refresh(_msg, socket), do: {:cont, socket} # Auto-apply admin layout for external plugin LiveViews. # Core views (PhoenixKitWeb.* and PhoenixKit.Modules.* bundled in :phoenix_kit) # handle layout via LayoutWrapper in their templates. External plugin views # (from extracted packages like :phoenix_kit_ecommerce, or parent app views) # need it applied at the session level so plugin authors don't need to wrap anything. defp maybe_apply_plugin_layout(socket) do view = socket.view if external_plugin_view?(view) do put_in(socket.private[:live_layout], {PhoenixKitWeb.Layouts, :admin}) else socket end end defp external_plugin_view?(view) do case Module.split(view) do ["PhoenixKitWeb" | _] -> false ["PhoenixKit", "Modules", _, "Web" | _] -> # Only treat as external if the module comes from a separate package. # Core modules bundled in :phoenix_kit handle their own layout via # LayoutWrapper.app_layout in their templates. not core_phoenix_kit_module?(view) ["PhoenixKit" | _] -> false _ -> true end end defp core_phoenix_kit_module?(view) do case :application.get_application(view) do {:ok, :phoenix_kit} -> true _ -> false end end # Priority-ordered list of admin sections to try when redirecting # a user who lacks access to the requested page. # Priority-ordered fallback routes for redirecting users who lack access. # Top-level module pages first, then settings sub-pages. @admin_fallback_routes [ # Core admin sections {"dashboard", "/admin"}, {"users", "/admin/users"}, {"settings", "/admin/settings"}, {"modules", "/admin/modules"}, {"media", "/admin/media"}, # Top-level feature module pages {"shop", "/admin/shop"}, {"posts", "/admin/posts"}, {"comments", "/admin/comments"}, {"billing", "/admin/billing"}, {"entities", "/admin/entities"}, {"customer_support", "/admin/customer-support/tickets"}, {"emails", "/admin/emails"}, {"ai", "/admin/ai"}, {"jobs", "/admin/jobs"}, {"db", "/admin/db"}, {"publishing", "/admin/publishing"}, # A user granted only "notifications" (their own inbox+settings) has no # "dashboard" access, so landing them here — not "/" — is their first # reachable admin page. {"notifications", "/admin/notifications"}, # Same for the two independent integration keys — a holder of only one of # these lands on its page rather than bouncing to "/". {"integrations", "/admin/settings/integrations"}, {"integrations_system", "/admin/settings/integrations/website"}, # Settings sub-pages (lower priority landing pages) {"languages", "/admin/settings/languages"}, {"seo", "/admin/settings/seo"}, {"sitemap", "/admin/settings/sitemap"}, {"maintenance", "/admin/settings/maintenance"}, {"legal", "/admin/settings/legal"}, {"referrals", "/admin/settings/referral-codes"} ] # Find the best admin page the user has access to, falling back to "/" # Checks both permission (user can access) AND enabled status (module is active) # to prevent redirect loops when a user has permission for a disabled module. defp best_available_admin_path(scope) do enabled = Permissions.enabled_module_keys() # Built-in routes first, then custom extension routes from admin tab config all_routes = @admin_fallback_routes ++ custom_admin_fallback_routes() Enum.find_value(all_routes, "/", fn {key, path} -> if Scope.has_module_access?(scope, key) and MapSet.member?(enabled, key), do: Routes.path(path) end) end # Builds fallback routes from custom admin tabs that have extension permission keys. # Only includes top-level tabs (no parent) with a permission not in the built-in set. defp custom_admin_fallback_routes do builtin = MapSet.new(Enum.map(@admin_fallback_routes, &elem(&1, 0))) case Application.get_env(:phoenix_kit, :admin_dashboard_tabs) do tabs when is_list(tabs) -> tabs |> Enum.filter(fn tab -> is_map(tab) and is_binary(tab[:permission]) and tab[:parent] == nil and not MapSet.member?(builtin, tab[:permission]) end) |> Enum.sort_by(& &1[:priority]) |> Enum.map(fn tab -> {tab.permission, tab.path} end) _ -> [] end end # Enforces module-level permission checks for admin views. # Extracted from on_mount(:phoenix_kit_ensure_admin) to reduce complexity. defp enforce_admin_view_permission(socket, scope) do if personal_admin_view?(socket.view) do {:cont, socket} else enforce_mapped_admin_view_permission(socket, scope) end end # Admin-chrome views showing a user their OWN data rather than granting an # administrative capability. Reaching admin chrome at all already requires # `can_access_admin_area?/1`, so this is not a hole — it says that once you # are in, reading your own mail is not a separate privilege. # # ⚠️ This has to be an explicit list, not an absence. Simply dropping these # views from `@admin_view_permissions` would make them *unmapped*, and the # unmapped fallback below is deliberately Owner-only — the opposite of the # intent. # A plain list, not a MapSet: a MapSet in a module attribute is inlined as a # literal, which leaks its internal representation past the opaque type. @personal_admin_views [ PhoenixKitWeb.Live.Notifications.Inbox, PhoenixKitWeb.Live.Notifications.Settings ] defp personal_admin_view?(view), do: view in @personal_admin_views # One warning per unmapped view, for ANY role — not only on a denial. # # This used to be a single `Logger.debug`, which meant a host could ship an # admin page, test it as Owner, see it work, and learn about the missing # mapping when a colleague hit a 403. Warning on the first mount regardless of # who mounts it surfaces the misconfiguration while the author is still # looking at the page. # # Deduped through `:persistent_term` keyed by the view module. That is # naturally bounded — it holds only the distinct unmapped LiveView modules # that ever mount, and module atoms are permanent in the BEAM anyway. The # process dictionary would not work: a LiveView is its own process, so it # would re-log on every mount. defp warn_unmapped_admin_view_once(view) do key = {__MODULE__, :unmapped_admin_view, view} case :persistent_term.get(key, :unseen) do :unseen -> :persistent_term.put(key, :warned) Logger.warning(""" [PhoenixKit] Admin view #{inspect(view)} has no permission mapping. Only a scope holding every enabled permission can reach it — so it works for an Owner and 403s for everyone else, including a named Admin whose keys have been partially revoked. Map it by giving its admin tab a `permission:` key, or by declaring the key in `config :phoenix_kit, :custom_permission_keys` and registering the view through a tab's `live_view:`. """) :warned -> :ok end end defp enforce_mapped_admin_view_permission(socket, scope) do case permission_key_for_admin_view(socket.view) do nil -> # Unmapped views (a host custom admin LV with no permission mapping): # allow ONLY a scope that can reach everything, fail closed for partial # roles. `holds_all_enabled_permissions?/1` is fully role-AGNOSTIC — it # is satisfied by Owner (all keys by construction), by the explicit `"*"` # superadmin key, and by any role granted every grantable permission. # # The former `or system_role?` arm was REMOVED: it let a named Admin # whose keys an Owner had partially revoked still reach unmapped views, # violating "no role is special for feature access". A default Admin # still passes because `holds_all_enabled_permissions?/1` compares # against the operator baseline (enabled keys MINUS opt-in ones), and # Admin auto-holds every one of those. To keep a deliberately-stripped # role's blanket access, an Owner grants it the `"*"` key — the # drift-immune, role-agnostic way. warn_unmapped_admin_view_once(socket.view) if Scope.holds_all_enabled_permissions?(scope) do {:cont, socket} else deny_admin_access(socket, scope) end module_key -> socket = Phoenix.Component.assign(socket, :phoenix_kit_current_module_key, module_key) module_enabled = Permissions.feature_enabled?(module_key) cond do # Disabled modules are blocked for all roles (including Owner/Admin) not module_enabled -> deny_module_disabled(socket, module_key) # Every role — Admin included — needs the permission key. Owner # passes because its scope holds every key by construction; Admin # holds keys as real rows (seeded/auto-granted, Owner-revocable). # The old system-role bypass here made Owner's revocations on the # Admin role effective everywhere EXCEPT fresh mounts — sidebar, # plugs, and the mid-session PubSub kick all honored them already. # # A tab may resolve to a dotted SUB-permission, and a raw sub-key # without its base is an orphan (`has_module_access?/2` leaves that # check to its caller). `can?/2` applies it, so route dotted keys # there rather than admitting an orphan the sidebar already hides. view_permission_held?(scope, module_key) -> {:cont, socket} true -> deny_admin_access(socket, scope) end end end defp view_permission_held?(scope, module_key) do if Permissions.parent_key(module_key) do Scope.can?(scope, module_key) else Scope.has_module_access?(scope, module_key) end end defp deny_module_disabled(socket, module_key) do label = Permissions.localized_module_label(module_key) # The label is localized, so the sentence around it must be too — an # English frame around a translated name reads worse than either alone. message = Gettext.dgettext(PhoenixKitWeb.Gettext, "default", "%{label} module is not enabled", label: label ) socket = socket |> Phoenix.LiveView.put_flash(:error, message) |> Phoenix.LiveView.redirect(to: Routes.path("/admin/modules")) {:halt, socket} end defp deny_admin_access(socket, scope) do redirect_to = best_available_admin_path(scope) socket = socket |> Phoenix.LiveView.put_flash( :error, "You do not have permission to access this section." ) |> Phoenix.LiveView.redirect(to: redirect_to) {:halt, socket} end # Check if user still has access to the currently viewed module defp has_current_module_access?(socket, scope) do case socket.assigns[:phoenix_kit_current_module_key] do nil -> true module_key -> Scope.has_module_access?(scope, module_key) end end # Maps admin LiveView modules to their permission keys. # Used by :phoenix_kit_ensure_admin to enforce module-level permissions # on core admin routes that share the same live_session. # Returns nil for unmapped views (allows access by default for backward compat). @admin_view_permissions %{ PhoenixKitWeb.Live.Dashboard => "dashboard", PhoenixKitWeb.Live.Modules => "modules", PhoenixKitWeb.Live.Users.Users => "users", PhoenixKitWeb.Users.UserForm => "users", PhoenixKitWeb.Live.Users.UserDetails => "users", PhoenixKitWeb.Live.Users.Roles => "users", PhoenixKitWeb.Live.Users.PermissionsMatrix => "users", PhoenixKitWeb.Live.Users.LiveSessions => "users", PhoenixKitWeb.Live.Users.Sessions => "users", PhoenixKitWeb.Live.Users.Media => "media", PhoenixKitWeb.Live.Users.MediaDetail => "media", PhoenixKitWeb.Live.Users.MediaSelector => "media", PhoenixKitWeb.Live.Settings => "settings", PhoenixKitWeb.Live.Settings.Users => "settings", PhoenixKitWeb.Live.Settings.Organization => "settings", # Login / OAuth / authorization settings — maps to the `settings` key. Like # the other Settings.* pages it resolves through no inference layer, so an # explicit entry is required or it falls through to the unmapped fallback. PhoenixKitWeb.Live.Settings.Authorization => "settings", # Integrations + Email Sending settings pages: none of these resolve # through the later inference layers (their PhoenixKitWeb namespace has no # ModuleRegistry entry), so an explicit mapping is required — unmapped # views fail closed and custom roles holding the "settings" permission # were denied pages whose nav links they could see. # System (website-wide) integrations moved off the broad `settings` key to # their own independent `integrations_system` key (grantable on its own). PhoenixKitWeb.Live.Settings.Integrations => "integrations_system", PhoenixKitWeb.Live.Settings.IntegrationForm => "integrations_system", # Personal per-user integrations — the independent `integrations` key. PhoenixKitWeb.Live.Integrations.MyIntegrations => "integrations", PhoenixKitWeb.Live.Integrations.MyIntegrationForm => "integrations", PhoenixKitWeb.Live.Settings.EmailSending => "settings", PhoenixKitWeb.Live.Settings.SendProfiles => "settings", PhoenixKitWeb.Live.Settings.SendProfileForm => "settings", PhoenixKitWeb.Live.Settings.SEO => "seo", PhoenixKitWeb.Live.Modules.Languages => "languages", PhoenixKitWeb.Live.Modules.Maintenance.Settings => "maintenance", PhoenixKitWeb.Live.Modules.Storage.Settings => "media", PhoenixKitWeb.Live.Modules.Storage.BucketForm => "media", PhoenixKitWeb.Live.Modules.Storage.Dimensions => "media", PhoenixKitWeb.Live.Modules.Storage.DimensionForm => "media", # Media health dashboard — same `media` key as the other Storage admin LVs. # (Its `PhoenixKitWeb.Live.Modules.Storage.*` namespace resolves through no # inference branch, so without this it fell through to the unmapped # fallback and a `media`-only custom role was wrongly denied it.) PhoenixKitWeb.Live.Modules.Storage.Health => "media", PhoenixKitWeb.Live.Modules.Jobs.Index => "jobs", # Notifications' two personal pages are NOT here — they are in # `@personal_admin_views`, because reading your own inbox is not an # administrative capability. The `notifications` key is reserved for an # all-users/moderation view (its context API survives for that rebuild: # `Notifications.admin_list/1`, `admin_stats/0`). # Activity feed / audit log — gated by `dashboard` (its admin tab is too). # These `PhoenixKitWeb.Live.Activity.*` modules resolve through no inference # layer, so without an explicit entry they hit the unmapped fallback — which # (post-fix) only a full-access scope passes, wrongly denying a custom role # that legitimately holds only `dashboard`. PhoenixKitWeb.Live.Activity.Index => "dashboard", PhoenixKitWeb.Live.Activity.Show => "dashboard" } # Resolve a LiveView module to its permission key. # # Resolution order (first non-nil wins): # # 1. `@admin_view_permissions` static map — core admin views. # 2. `infer_permission_from_custom_tabs/1` — modules that registered # admin tabs with a `live_view:` field. # 3. `PhoenixKit.Modules..Web.*` namespace inference — internal # modules under the core namespace. # 4. Plugin top-level namespace via `ModuleRegistry` — external # packages whose top-level module name matches a registered # module (e.g. `PhoenixKitEntities` → `"entities"`). # # Returns `nil` for views that don't resolve through any path — # callers must apply their own fail-closed default. # # Exposed as `@doc false def` (rather than `defp`) so unit tests can # exercise the resolution layers directly without LiveView mounting # machinery. Not part of the public API. @doc false def permission_key_for_admin_view(view_module) do case Map.get(@admin_view_permissions, view_module) do nil -> infer_permission_from_custom_tabs(view_module) || infer_permission_key_from_module(view_module) key -> key end end # Looks up permission key from cached custom view → permission mapping. # This mapping is populated at Registry init time from :admin_dashboard_tabs config. defp infer_permission_from_custom_tabs(view_module) do Permissions.custom_view_permissions() |> Map.get(view_module) end # Infer permission key from `PhoenixKit.Modules..Web.*` (core) or from a # registered external plugin's top-level namespace # (`PhoenixKitEntities.*`, `PhoenixKitBilling.*`, …) via `ModuleRegistry`. defp infer_permission_key_from_module(view_module) do case Module.split(view_module) do ["PhoenixKit", "Modules", module_name | _rest] -> Macro.underscore(module_name) [top | _rest] -> ModuleRegistry.get_module_key_for_namespace(top) end end # Applies maintenance mode layout for non-admin users when maintenance is active. # Instead of redirecting, overrides the LiveView's layout so the maintenance page # renders in place of whatever page the user is on. URL never changes. # Also attaches a PubSub hook so that when maintenance status changes, # the page updates automatically without requiring navigation. # # Called from mount_phoenix_kit_current_scope/3 so every live_session that uses # a scope-mounting on_mount hook automatically inherits maintenance mode # enforcement. New on_mount hooks don't need to remember to call this. defp check_maintenance_mode(socket) do # Clean up stale state if the scheduled end time has passed Maintenance.cleanup_expired_schedule() if Maintenance.active?() do scope = socket.assigns[:phoenix_kit_current_scope] cond do # Auth routes must always be accessible (login, password reset, etc.) auth_route?(socket) -> maybe_attach_maintenance_hook(socket) # Admins and owners bypass maintenance mode scope && (Scope.can_access_admin_area?(scope) || Scope.owner?(scope)) -> maybe_attach_maintenance_hook(socket) # Everyone else gets the maintenance layout true -> socket |> save_original_layout() |> put_in([Access.key(:private), :live_layout], {PhoenixKitWeb.Layouts, :maintenance}) |> maybe_attach_maintenance_hook() end else socket |> save_original_layout() |> maybe_attach_maintenance_hook() end end # Save the current layout so we can restore it when maintenance ends defp save_original_layout(socket) do if socket.assigns[:phoenix_kit_original_layout] do socket else Phoenix.Component.assign(socket, :phoenix_kit_original_layout, socket.private[:live_layout]) end end # Attach a PubSub hook that listens for maintenance status changes. # When maintenance toggles on/off, forces a remount so the layout is re-evaluated. # # on_mount runs twice: disconnected (HTTP) then connected (WebSocket). # The hook is attached on the first run, but PubSub subscription must happen # on the connected mount — so we track them with separate flags. defp maybe_attach_maintenance_hook(socket) do socket = if Phoenix.LiveView.connected?(socket) and !socket.assigns[:phoenix_kit_maintenance_subscribed?] do Maintenance.subscribe() socket |> Phoenix.Component.assign(:phoenix_kit_maintenance_subscribed?, true) |> reschedule_maintenance_end_timer() else socket end if socket.assigns[:phoenix_kit_maintenance_hook_attached?] do socket else socket |> attach_hook(:phoenix_kit_maintenance, :handle_info, &handle_maintenance_change/2) |> Phoenix.Component.assign(:phoenix_kit_maintenance_hook_attached?, true) end end # Erlang's Process.send_after/3 takes at most a 32-bit timeout in ms (~24.8 days). # validate_schedule/2 caps schedules at 1 year, but clamp here defensively. @max_timer_ms 2_147_483_647 # Cancels any stale timer and schedules a new one for the current scheduled end. # Called on connected mount and whenever the schedule changes via PubSub. # This handles the case where a user is sitting on a maintenance-blocked page # and the scheduled end time arrives — they get unblocked automatically. defp reschedule_maintenance_end_timer(socket) do # Cancel any existing timer so we don't leak a fire-after-schedule-changed signal. case socket.assigns[:phoenix_kit_maintenance_timer_ref] do ref when is_reference(ref) -> Process.cancel_timer(ref) _ -> :ok end timer_ref = case Maintenance.get_scheduled_end() do %DateTime{} = end_dt -> seconds = DateTime.diff(end_dt, DateTime.utc_now()) if seconds > 0 do timeout_ms = min(seconds * 1000, @max_timer_ms) Process.send_after( self(), {:maintenance_status_changed, %{active: false}}, timeout_ms ) end _ -> nil end Phoenix.Component.assign(socket, :phoenix_kit_maintenance_timer_ref, timer_ref) end defp handle_maintenance_change({:maintenance_status_changed, _payload}, socket) do scope = socket.assigns[:phoenix_kit_current_scope] if scope && (Scope.can_access_admin_area?(scope) || Scope.owner?(scope)) do {:cont, socket} else # Schedule may have changed — re-read the end time and reset the auto-off timer. # Note: we intentionally distrust the payload's :active value and re-check via # Maintenance.active?/0 so late-arriving stale messages don't drive the UI. socket = reschedule_maintenance_end_timer(socket) if Maintenance.active?() do # Swap to maintenance layout — LiveView keeps running, form state preserved. # Must also touch an assign to trigger a re-render (private changes alone don't). # HACK: socket.private[:live_layout] is Phoenix LiveView internals (same pattern # used by maybe_apply_plugin_layout). Revisit if Phoenix.LiveView 1.x changes. socket = socket |> put_in([Access.key(:private), :live_layout], {PhoenixKitWeb.Layouts, :maintenance}) |> Phoenix.Component.assign(:phoenix_kit_maintenance_active, true) {:halt, socket} else # Restore the original layout — form state still preserved in assigns. # If original was nil (no layout set), remove the key entirely instead of # setting it to nil, which would crash the renderer. original = socket.assigns[:phoenix_kit_original_layout] socket = if original do put_in(socket.private[:live_layout], original) else %{socket | private: Map.delete(socket.private, :live_layout)} end socket = Phoenix.Component.assign(socket, :phoenix_kit_maintenance_active, false) {:halt, socket} end end end defp handle_maintenance_change(_msg, socket), do: {:cont, socket} # Auth views that must always be accessible during maintenance defp auth_route?(socket) do case socket.view do PhoenixKitWeb.Users.Login -> true PhoenixKitWeb.Users.ForgotPassword -> true PhoenixKitWeb.Users.ResetPassword -> true PhoenixKitWeb.Users.MagicLink -> true PhoenixKitWeb.Users.MagicLinkRegistrationRequest -> true PhoenixKitWeb.Users.MagicLinkRegistration -> true PhoenixKitWeb.Users.Confirmation -> true PhoenixKitWeb.Users.ConfirmationInstructions -> true _ -> false end end defp refresh_scope_assigns(socket) do case socket.assigns[:phoenix_kit_current_user] do %User{uuid: user_uuid} -> case Auth.get_user(user_uuid) do %User{} = user -> scope = Scope.for_user(user) socket = socket |> Phoenix.Component.assign(:phoenix_kit_current_user, user) |> Phoenix.Component.assign(:phoenix_kit_current_scope, scope) |> maybe_manage_scope_subscription(user) {socket, scope} nil -> scope = Scope.for_user(nil) socket = socket |> Phoenix.Component.assign(:phoenix_kit_current_user, nil) |> Phoenix.Component.assign(:phoenix_kit_current_scope, scope) |> maybe_unsubscribe_scope_updates() {socket, scope} end _ -> scope = socket.assigns[:phoenix_kit_current_scope] || Scope.for_user(nil) {socket, scope} end end @doc false def init(opts), do: opts @doc false def call(conn, :fetch_phoenix_kit_current_user), do: fetch_phoenix_kit_current_user(conn, []) @doc false def call(conn, :fetch_phoenix_kit_current_scope), do: fetch_phoenix_kit_current_scope(conn, []) @doc false def call(conn, :phoenix_kit_redirect_if_user_is_authenticated), do: redirect_if_user_is_authenticated(conn, []) @doc false def call(conn, :phoenix_kit_require_authenticated_user), do: require_authenticated_user(conn, []) @doc false def call(conn, :phoenix_kit_require_authenticated_scope), do: require_authenticated_scope(conn, []) @doc false def call(conn, :phoenix_kit_validate_and_set_locale), do: validate_and_set_locale(conn, []) @doc false def call(conn, :phoenix_kit_require_admin), do: require_admin(conn, []) @doc """ Used for routes that require the user to not be authenticated. """ def redirect_if_user_is_authenticated(conn, _opts) do if conn.assigns[:phoenix_kit_current_user] do conn |> redirect(to: signed_in_path(conn)) |> halt() else conn end end @doc """ Used for routes that require the user to be authenticated. Enforces email confirmation before allowing access to the application. """ def require_authenticated_user(conn, _opts) do user = conn.assigns[:phoenix_kit_current_user] if is_nil(user) do conn |> put_flash(:error, "You must log in to access this page.") |> maybe_store_return_to() |> redirect(to: Routes.path("/users/log-in")) |> halt() else case account_gate(user) do :ok -> conn {:halt, message, target} -> conn |> put_flash(:error, message) |> maybe_store_return_to() |> redirect(to: Routes.path(target)) |> halt() end end end @doc """ Used for routes that require the user to be authenticated via scope. This function checks authentication status through the scope system, providing a more structured approach to authentication checks. Enforces email confirmation before allowing access to the application. """ def require_authenticated_scope(conn, _opts) do case conn.assigns[:phoenix_kit_current_scope] do %Scope{} = scope -> if Scope.authenticated?(scope) do case enforce_account_gate(conn, scope) do {:halt, conn} -> conn :cont -> conn end else conn |> put_flash(:error, "You must log in to access this page.") |> maybe_store_return_to() |> redirect(to: Routes.path("/users/log-in")) |> halt() end _ -> # Scope not found, try to create it from current_user conn |> fetch_phoenix_kit_current_scope([]) |> require_authenticated_scope([]) end end defp email_confirmed?(%Scope{user: user}), do: email_confirmed?(user) defp email_confirmed?(%{confirmed_at: confirmed_at}) when not is_nil(confirmed_at), do: true defp email_confirmed?(_), do: false # Everything an already-authenticated account must satisfy before it may use # the application, in the order the user should be walked through them. This # is the single definition behind all five `on_mount` hooks and all four # role/permission plugs — a gate added here is enforced everywhere at once, # which is how the invite-only gate reached eleven call sites without eleven # copies of the rule. # # Returns `:ok`, or `{:halt, flash_message, path}` where `path` is the page # that will unblock the user. Callers turn that into a conn or socket # redirect and are responsible for carrying `return_to`. defp account_gate(subject) do cond do not email_confirmed?(subject) and confirmation_required?() -> {:halt, "Please confirm your email before accessing the application.", "/users/confirm"} not Referrals.access_satisfied?(subject) -> {:halt, "Enter your referral code to continue.", "/users/referral"} true -> :ok end end # Whether unconfirmed accounts are blocked from authenticated routes. # Host-configurable via the `require_email_confirmation` setting (default # true = historical behavior). Confirmation emails still send when off — # only enforcement is gated. defp confirmation_required? do PhoenixKit.Settings.get_boolean_setting("require_email_confirmation", true) end # Conn side of `account_gate/1`, for the role-based plugs. They check the role # but never the account state, and the shipped `:phoenix_kit_admin_only` # pipeline runs `require_admin` with NO preceding `require_authenticated_*` — # so a host controller route behind it enforced confirmation nowhere, even at # the default. Returns `:cont` or an already-halted conn. # # An anonymous visitor passes straight through: each caller has its own # not-logged-in branch, and parking a visitor with no account on a page that # asks them to satisfy their account is nonsense. defp enforce_account_gate(conn, scope) do with true <- Scope.authenticated?(scope), {:halt, message, target} <- account_gate(scope) do {:halt, conn |> put_flash(:error, message) |> maybe_store_return_to() |> redirect(to: Routes.path(target)) |> halt()} else _ -> :cont end end @doc """ Used for routes that require the user to be an owner. If you want to enforce the owner requirement without redirecting to the login page, consider using `:phoenix_kit_require_authenticated_scope` instead. """ def require_owner(conn, _opts) do case conn.assigns[:phoenix_kit_current_scope] do %Scope{} = scope -> case enforce_account_gate(conn, scope) do {:halt, conn} -> conn :cont -> if Scope.owner?(scope) do conn else conn |> put_flash(:error, "You must be an owner to access this page.") |> redirect(to: "/") |> halt() end end _ -> # Scope not found, try to create it from current_user conn |> fetch_phoenix_kit_current_scope([]) |> require_owner([]) end end @doc """ Used for routes that require the user to be an admin or owner. If you want to enforce the admin requirement without redirecting to the login page, consider using `:phoenix_kit_require_authenticated_scope` instead. """ def require_admin(conn, _opts) do case conn.assigns[:phoenix_kit_current_scope] do %Scope{} = scope -> case enforce_account_gate(conn, scope) do {:halt, conn} -> conn :cont -> cond do Scope.can_access_admin_area?(scope) -> conn Scope.authenticated?(scope) -> conn |> put_flash( :error, "You do not have the required permission to access this page." ) |> redirect(to: "/") |> halt() true -> conn |> put_flash(:error, "You must log in to access this page.") |> redirect(to: Routes.path("/users/log-in")) |> halt() end end _ -> conn |> fetch_phoenix_kit_current_scope([]) |> require_admin([]) end end @doc """ Used for routes that require the user to have module-level permission. """ def require_module_access(conn, module_key) when is_binary(module_key) do case conn.assigns[:phoenix_kit_current_scope] do %Scope{} = scope -> case enforce_account_gate(conn, scope) do {:halt, conn} -> conn :cont -> cond do not Scope.can_access_admin_area?(scope) -> conn |> put_flash( :error, "You do not have the required permission to access this page." ) |> redirect(to: "/") |> halt() # Disabled modules are blocked for everyone (Owner included), matching # the LiveView mount and plug gates. Scope.has_module_access?(scope, module_key) and MapSet.member?(Permissions.enabled_module_keys(), module_key) -> conn true -> conn |> put_flash(:error, "You do not have permission to access this section.") |> redirect(to: best_available_admin_path(scope)) |> halt() end end _ -> conn |> fetch_phoenix_kit_current_scope([]) |> require_module_access(module_key) end end def require_role(conn, role_name) when is_binary(role_name) do case conn.assigns[:phoenix_kit_current_scope] do %Scope{} = scope -> case enforce_account_gate(conn, scope) do {:halt, conn} -> conn :cont -> if Scope.has_role?(scope, role_name) do conn else conn |> put_flash(:error, "You must have the #{role_name} role to access this page.") |> redirect(to: "/") |> halt() end end _ -> # Scope not found, try to create it from current_user conn |> fetch_phoenix_kit_current_scope([]) |> require_role(role_name) end end defp put_token_in_session(conn, token) do conn |> put_session(:user_token, token) |> put_session(:live_socket_id, "phoenix_kit_sessions:#{Base.url_encode64(token)}") end defp maybe_store_return_to(%{method: "GET"} = conn) do put_session(conn, :user_return_to, current_path(conn)) end defp maybe_store_return_to(conn), do: conn # LiveView counterpart to maybe_store_return_to/1: builds a login URL # carrying the original request path as a ?return_to= query param. # The login LiveView reads it (sanitize_return_to/1), the form posts # it back, session.ex stashes it as :user_return_to, log_in_user/3 # redirects there. We skip the param when the URI isn't available or # the user is already on the login page (guards against self-loops). defp login_path_with_return_to(socket) do path_with_return_to(socket, "/users/log-in") end # The self-loop check trims trailing slashes on both sides so # `/users/log-in` and `/users/log-in/` are treated as the same path — # without the trim, a hand-typed trailing-slash URL would round-trip # back to itself via `?return_to=`. defp path_with_return_to(socket, target) do target_path = Routes.path(target) target_path_canonical = String.trim_trailing(target_path, "/") case Phoenix.LiveView.get_connect_info(socket, :uri) do %URI{path: path} = uri when is_binary(path) -> if String.trim_trailing(path, "/") == target_path_canonical do target_path else # `""` is truthy in Elixir, so a plain `if uri.query` turned a # query-less-but-present query string into a bare `"?"` — and the URI # comes from the browser's location, which carries one after a # fieldless GET submit or a `push_patch` with no params. Captured # once, it round-trips back into the URL and perpetuates itself. query = if uri.query in [nil, ""], do: "", else: "?" <> uri.query target_path <> "?return_to=" <> URI.encode_www_form(path <> query) end _ -> target_path end end # Default post-login destination — the `after_login_path` setting, guarded. # An explicit :user_return_to (stashed by the gates above or passed by the # login form) is applied by `log_in_user/3` and wins over this default. defp signed_in_path(_conn), do: Routes.post_auth_path() @doc """ Validates and sets the locale for the current request. This function is called as a plug in the router to validate locale codes in the URL path. It implements PhoenixKit's simplified URL architecture: - URLs use base language codes (en, es, fr) for simplicity - Full dialect codes (en-US, es-MX) are redirected to base codes (301) - User preferences determine which dialect variant to use for translations - Translation system uses full dialect codes internally ## Data Flow 1. Check if URL contains full dialect code → redirect to base 2. Validate base code exists in predefined language list 3. Resolve to full dialect using user preference or default mapping 4. Set Gettext to full dialect for translations 5. Store both base code (for URLs) and full dialect (for translations) ## Examples # Base code in URL (preferred format) conn = validate_and_set_locale(conn, []) # Sets: current_locale_base="en", current_locale="en-US" # Full dialect in URL (legacy/bookmarks) conn = validate_and_set_locale(%{path_params: %{"locale" => "en-US"}}, []) # Redirects 301 to: /en/... # Invalid locale in URL conn = validate_and_set_locale(%{path_params: %{"locale" => "xx"}}, []) # Redirects to default locale URL """ def validate_and_set_locale(conn, _opts) do # Direct locale processing - dialect preferences are handled via LiveView events process_locale(conn) end # Reserved path segments that should never be treated as locale codes # These are valid URL path components that happen to match the locale pattern position @reserved_path_segments ~w(admin api webhooks assets static files images dashboard users) # Locale processing logic # # The locale segment normally binds as `path_params["locale"]`, but # `phoenix_kit_publishing`'s internal localized-content routes # (`integration.ex`'s `get "/:language/:group"` scope) bind the same # segment as `path_params["language"]` instead. Accept either so # Gettext gets set correctly on those routes too — otherwise this # falls through to the default-locale branch and headers/translations # silently render in the site default language regardless of the URL. defp process_locale(conn) do locale_param = conn.path_params["locale"] || conn.path_params["language"] case locale_param do locale when is_binary(locale) -> cond do # Check if this is a reserved path segment (admin, api, etc.) # These should be treated as regular paths, not locale codes locale in @reserved_path_segments -> process_as_default_locale(conn) # An ENABLED full dialect ("en-gb"/"en-GB" with en-GB enabled) is a # real localized URL — publishing's sibling-dialect URLs (two # enabled dialects of one base, the non-primary one addressed by # its lowercase full code) depend on this branch. Only exact # enabled membership qualifies, matched case-insensitively; every # other hyphenated segment keeps the historical redirect to base. dialect = enabled_full_dialect(locale) -> process_enabled_dialect_locale(conn, dialect) # Check if this is a full dialect code (contains hyphen) → redirect to base String.contains?(locale, "-") -> redirect_to_base_locale(conn, locale) # Validate base code exists in predefined language list AND is enabled DialectMapper.valid_base_code?(locale) and locale_allowed?(locale) -> process_valid_locale(conn, locale) # Valid predefined but not enabled → redirect to default DialectMapper.valid_base_code?(locale) -> redirect_invalid_locale(conn, locale) # Invalid base code → redirect to default true -> redirect_invalid_locale(conn, locale) end _ -> # No locale in URL → primary language. Pure URL → default, matching # the LV mount path (`mount_phoenix_kit_current_scope/3`). Previously # this branch consulted `user.custom_fields["preferred_locale"]` # before the default, but the LV mount doesn't read it — a # logged-in user with preferred_locale=de visiting `/admin/foo` # would see German for the initial HTTP response and then English # after the LV mount snapped to default. The two paths now agree: # URL is the only source of truth. The preferred_locale field is # still written by the switcher (so the data is preserved for any # future feature that wants to re-enable it) but never read for # routing — and that includes dialect resolution: `resolve_dialect/1` # takes no user, so user-preferred dialect upgrades (e.g. base # "en" → "en-GB" via custom_fields) cannot sneak back in. default_base = Routes.get_default_admin_locale() default_dialect = DialectMapper.resolve_dialect(default_base) Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) Gettext.put_locale(default_dialect) conn |> assign(:current_locale_base, default_base) |> assign(:current_locale, default_dialect) end end # Process request as default locale (for reserved path segments like "admin", "api") # When a reserved segment is captured as locale, redirect to remove the locale segment # Example: /:locale/dashboard captures /admin with locale="admin" # We redirect to /dashboard so the correct route can match defp process_as_default_locale(conn) do locale = conn.path_params["locale"] || conn.path_params["language"] # Set locale before redirecting. URL-driven dialect — no user (see # `process_valid_locale/2` for the rationale). default_base = Routes.get_default_admin_locale() default_dialect = DialectMapper.resolve_dialect(default_base) Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) Gettext.put_locale(default_dialect) case strip_locale_segment(conn, locale) do {:ok, clean_path} -> # Redirect to clean path so the router matches the correct route. # # A REAL 307, which the old code only claimed to send: it passed # `status: 307` to `Phoenix.Controller.redirect/2`, which sends # `conn.status || 302` and ignores an `:status` option entirely. # The status has to be set on the conn first. # # 307 rather than 302 because this pipeline carries the localized # non-live auth endpoints — `post "/:locale/users/log-in"` and # friends (see `generate_localized_routes/1`). A 302 is replayed # by the browser as a GET, so a login POST that happened to land # on a reserved segment would arrive at the corrected URL with its # body silently dropped. 307 preserves method and body, which is # what "you asked for the right resource under a wrong prefix" # should mean. Safe to cache-bust freely: unlike a 301 this is # explicitly temporary. conn |> put_status(:temporary_redirect) |> Phoenix.Controller.redirect(to: with_query_string(clean_path, conn)) |> halt() :error -> # The locale segment could not be located in the path, so there is # no cleaner URL to send the user to. Historically this branch # redirected to `clean_path` anyway — and because the old strip # was prefix-blind (`String.replace_prefix(request_path, # "/#{locale}", "")` is a no-op on `/phoenix_kit/admin/shop`), it # emitted a redirect straight back to the same URL and the browser # spun until it gave up. Never redirect to a path we did not # actually change: fall through and let the matched route render # under the default locale instead. conn |> assign(:current_locale_base, default_base) |> assign(:current_locale, default_dialect) end end # Rebuild the request path with the mis-captured locale segment removed, # preserving both the mount prefix and any enclosing `forward` mount. # # `conn.request_path` includes the PhoenixKit mount prefix # ("/phoenix_kit/admin/shop"), while `locale` is the segment that # follows it — so the prefix has to be skipped before dropping the # segment, and re-attached afterwards. Returns `:error` when the path # does not have the expected `//...` shape, so callers # can decline to redirect rather than bounce the request at itself. # # Two subtleties, both of which produced wrong URLs when this was first # written against `path_info` alone: # # * **`script_name`.** `conn.path_info` is router-relative; a router # reached through `Plug.forward` (a `/tenant` mount, say) carries # the mount segments in `conn.script_name` instead. Rebuilding from # `path_info` only would emit `/phoenix_kit/shop` for a request to # `/tenant/phoenix_kit/api/shop` — a redirect straight out of the # mount. The old `request_path`-based code got this right by # accident, so it has to be restored explicitly. # # * **percent-encoding.** Phoenix matches routes against a DECODED # copy of the path (`Phoenix.Router.call/2` does # `Enum.map(path_info, &URI.decode/1)` before `__match_route__`), # but leaves `conn.path_info` encoded. So `/phoenix_kit/%61pi/shop` # binds `path_params["locale"] == "api"` while `path_info` still # holds `"%61pi"`, and a raw comparison silently misses. Compare # decoded segments; emit the RAW ones, so the redirect target keeps # whatever encoding the client sent. defp strip_locale_segment(conn, locale) when is_binary(locale) do prefix_segments = PhoenixKit.Config.get_url_prefix() |> to_string() |> String.split("/", trim: true) prefix_length = length(prefix_segments) decoded = Enum.map(conn.path_info, &URI.decode/1) with {^prefix_segments, [^locale | _]} <- Enum.split(decoded, prefix_length), {_prefix, [_locale | rest]} <- Enum.split(conn.path_info, prefix_length) do {:ok, "/" <> Enum.join(conn.script_name ++ prefix_segments ++ rest, "/")} else _ -> :error end end defp strip_locale_segment(_conn, _locale), do: :error # Characters `Phoenix.Controller.redirect/2` refuses in a local target. # Mirrored here because the list is private to Phoenix and a query string # is arbitrary client input — see `with_query_string/2`. @unsafe_redirect_chars ["\\", "/%09", "/\t"] # Carry the query string across a locale redirect. # # Every locale redirect in this module rebuilds a PATH — `request_path` # and `path_info` both stop at the "?" — so the query was dropped on the # floor, and `redirect_to_base_locale/2` documented the opposite ("Query # parameters preserved", with a `?page=2` example that never worked). # On this surface that is not cosmetic: `Routes.return_to_query/1` # threads `return_to` through exactly these URLs, so a visitor arriving # at `//en-US/users/log-in?return_to=/admin/x` lost their # destination and landed on the post-login default instead. # # Drop the query rather than raise when it contains something # `redirect/2` rejects: a client can send a raw backslash in a query # value, and a 500 would be worse than the truncation we ship today. defp with_query_string(path, conn) do case conn.query_string do "" -> path query -> candidate = path <> "?" <> query if String.contains?(candidate, @unsafe_redirect_chars), do: path, else: candidate end end defp locale_allowed?(base_code) do language_enabled?(base_code) end # The stored-case enabled code for a hyphenated URL segment ("en-gb" → # "en-GB"), nil when the segment isn't an enabled dialect. An empty # enabled list means the Languages module is effectively off — fall # through to the historical hyphen→base redirect rather than accepting # arbitrary dialects. defp enabled_full_dialect(locale) do if String.contains?(locale, "-") do down = String.downcase(locale) Enum.find_value(Languages.get_enabled_languages(), fn lang -> if String.downcase(lang.code) == down, do: lang.code end) end end # Mirrors the else-branch of process_valid_locale/2 for an exact enabled # dialect: the URL already names the full dialect, so there is nothing to # resolve — set Gettext and expose base + full on the conn. The # prefixless-primary redirect never applies here: only non-primary # siblings are addressed by full-code URLs (the primary's URLs stay # base-coded), so a dialect segment is never the default locale. defp process_enabled_dialect_locale(conn, dialect) do Gettext.put_locale(PhoenixKitWeb.Gettext, dialect) Gettext.put_locale(dialect) conn |> assign(:current_locale_base, DialectMapper.extract_base(dialect)) |> assign(:current_locale, dialect) end # Check if a language (base code) is enabled in the system # Returns true if the language is in the enabled languages list or if Languages module is disabled defp language_enabled?(base_code) do case Languages.get_enabled_languages() do [] -> # No languages enabled, allow all predefined languages true enabled_languages -> # Check if base_code matches any enabled language Enum.any?(enabled_languages, fn lang -> lang_base = DialectMapper.extract_base(lang.code) lang_base == base_code end) end end # Process a validated and enabled locale. # For non-admin paths: redirect default locale to clean URL (no prefix needed). # Admin paths: do NOT redirect — both `/phoenix_kit/admin/*` and # `/phoenix_kit//admin/*` are valid (the dual-scope router # emission accepts both), and `Routes.admin_path/2` emits the # prefixless shape by default. We honor whichever URL shape the user # typed; both shapes resolve into the same `live_session # :phoenix_kit_admin`, so there is no boundary to canonicalize away. defp process_valid_locale(conn, locale) do if locale == Routes.get_default_admin_locale() and not admin_request?(conn) and prefixless_primary?() do redirect_default_locale_to_clean_url(conn, locale) else # URL-driven dialect — no user (see `process_as_default_locale/1` # for the rationale matching the LV mount path). full_dialect = DialectMapper.resolve_dialect(locale) Gettext.put_locale(PhoenixKitWeb.Gettext, full_dialect) Gettext.put_locale(full_dialect) conn |> assign(:current_locale_base, locale) |> assign(:current_locale, full_dialect) end end # Reads the site-wide `default_language_no_prefix` setting. When ON, # primary-language URLs are emitted prefixless and this plug # 301-redirects `//...` to the prefixless shape to keep one # canonical URL. When OFF (the default), the `//...` shape # IS the canonical URL and must NOT be redirected — a 301 would # discard any POST body. Defers to the canonical boot-safe wrapper # on `Languages` so this plug + `Routes` share one rescue policy. defp prefixless_primary?, do: PhoenixKit.Modules.Languages.prefixless_primary_safe?() # Check if the request path is an admin path. Used by # `process_valid_locale/2` to skip the default-locale-redirect so # both `/phoenix_kit/admin/*` and `/phoenix_kit//admin/*` # render whichever shape the user typed. # Whether this request targets the admin area. # # Matches "admin" as a whole PATH SEGMENT, not as a substring. The old # `String.contains?(request_path, "/admin")` classified any URL with # those characters anywhere in it — a storefront product at # `/shop/product/admin-tools`, a blog post at `/news/admin-changes` — as # an admin request, which then skipped default-locale canonicalisation # for those pages. # # Segment-wise matching also means a genuine admin URL still matches # under any mount prefix and any locale segment, since we only care # whether "admin" appears as its own segment. # # Decoded, for the reason spelled out on `strip_locale_segment/2`: # Phoenix binds routes against a decoded copy of the path but leaves # `conn.path_info` encoded, so `//en/%61dmin/users` reaches the # admin route while a raw comparison reports "not admin" and hands the # request to the default-locale canonicaliser. defp admin_request?(conn) do Enum.any?(conn.path_info, &(URI.decode(&1) == "admin")) end # Redirects default language URLs to clean URLs (no locale prefix) # Example: /phoenix_kit/en/dashboard → /phoenix_kit/dashboard # # This sends a 302, NOT the 301 the code used to ask for. Two reasons, # and they point the same way: # # 1. It never was a 301. `Phoenix.Controller.redirect/2` sends # `conn.status || 302` and ignores an `:status` option entirely, # so the `status: 301` that used to sit on this call was dead. # 2. A real 301 here would be a bug. Which language is "default" is a # runtime setting an admin can change; browsers and crawlers cache # 301s ~forever, so flipping the default from en to et would leave # clients permanently redirecting `/en/...` away from a locale that # had become a legitimate URL. The accidental 302 was the correct # behaviour all along. # # If canonicalisation for SEO is wanted, express it with a # `` — not with a cached permanent redirect. defp redirect_default_locale_to_clean_url(conn, locale) do # Remove /en/ from path: /phoenix_kit/en/admin → /phoenix_kit/admin clean_path = conn.request_path |> String.replace("/#{locale}/", "/", global: false) |> then(fn path -> # Handle case where locale is at end of path: /phoenix_kit/en → /phoenix_kit if String.ends_with?(conn.request_path, "/#{locale}") do String.replace_suffix(path, "/#{locale}", "") else path end end) # Ensure we have a valid path (not empty) clean_path = if clean_path == "", do: "/", else: clean_path conn |> Phoenix.Controller.redirect(to: with_query_string(clean_path, conn)) |> halt() end @doc """ Redirects full dialect code URLs to base language URLs. This function handles backward compatibility by redirecting old URLs with full dialect codes (en-US, es-MX) to the new simplified base code URLs (en, es). Sends a **302**. The docs here used to promise a 301, and the call passed `status: 301` — but `Phoenix.Controller.redirect/2` sends `conn.status || 302` and ignores an `:status` option, so no 301 was ever emitted. The option has been removed rather than made real: a cached permanent redirect off a dialect code is hard to walk back if the dialect set changes, and nothing here needs permanence. Use `put_status(:moved_permanently)` before `redirect/2` if that ever changes. ## Examples iex> redirect_to_base_locale(conn, "en-US") # /phoenix_kit/en-US/admin → /phoenix_kit/en/admin iex> redirect_to_base_locale(conn, "es-MX") # /phoenix_kit/es-MX/users?page=2 → /phoenix_kit/es/users?page=2 ## Preservation - Query parameters preserved (via `with_query_string/2` — they were not, before: this rebuilds a path, and `conn.request_path` stops at the "?") - Request method unchanged (GET → GET) - Full path structure maintained URL fragments are NOT preserved, and cannot be: a fragment never leaves the browser, so the server has nothing to copy. The client re-applies its own fragment to the `Location` it follows. ## Notes - Halts conn pipeline (no further processing) - Logged for monitoring migration patterns """ def redirect_to_base_locale(conn, full_dialect) do base_code = DialectMapper.extract_base(full_dialect) # Replace first occurrence of full dialect with base code # Handles: /phoenix_kit/en-US/admin → /phoenix_kit/en/admin corrected_path = String.replace( conn.request_path, "/#{full_dialect}/", "/#{base_code}/", global: false ) # Handle case where dialect is at end of path # Handles: /phoenix_kit/en-US → /phoenix_kit/en corrected_path = if String.ends_with?(conn.request_path, "/#{full_dialect}") do String.replace_suffix(corrected_path, "/#{full_dialect}", "/#{base_code}") else corrected_path end # Log redirect for monitoring (helps track migration patterns) Logger.info(""" [PhoenixKit Locale] Redirecting full dialect URL to base code - Full dialect: #{full_dialect} - Base code: #{base_code} - Original path: #{conn.request_path} - Corrected path: #{corrected_path} """) conn |> Phoenix.Controller.redirect(to: with_query_string(corrected_path, conn)) |> halt() end @doc """ Redirects invalid locale URLs to the canonical default-locale shape. Takes the current URL path and replaces the invalid locale segment so the redirect target matches the rest of the app's URL emission: - With `default_language_no_prefix?` ON → strip the segment entirely (the canonical primary shape is prefixless, e.g. `/phoenix_kit/admin`). - With the setting OFF (default) → swap the invalid segment for the primary base code so the canonical prefixed shape is preserved (e.g. `/phoenix_kit/xx/admin` → `/phoenix_kit/en/admin`). """ def redirect_invalid_locale(conn, invalid_locale) do # Get the default language default_base = Routes.get_default_admin_locale() # Single read of the setting — the two replacement variants always # flip together (prefixless ↔ prefixed), so evaluating the flag # twice would just risk torn reads if a concurrent setting flip # landed between the two calls. {replacement_segment, replacement_suffix} = if prefixless_primary?(), do: {"/", ""}, else: {"/#{default_base}/", "/#{default_base}"} corrected_path = conn.request_path |> String.replace("/#{invalid_locale}/", replacement_segment, global: false) |> then(fn path -> if String.ends_with?(conn.request_path, "/#{invalid_locale}") do String.replace_suffix(path, "/#{invalid_locale}", replacement_suffix) else path end end) # Log the invalid locale attempt for debugging Logger.warning(""" [PhoenixKit Locale] Invalid locale requested, redirecting to default - Invalid locale: #{invalid_locale} - Default base: #{default_base} - Original path: #{conn.request_path} - Corrected path: #{corrected_path} """) # Redirect to the corrected URL conn |> redirect(to: corrected_path) |> halt() end defp extract_session_id_from_live_socket_id(live_socket_id) do case live_socket_id do "phoenix_kit_sessions:" <> encoded_token -> # Use first 8 chars of encoded token as session_id for admin display String.slice(encoded_token, 0, 8) _ -> "unknown" end end @doc false def broadcast_disconnect_for_socket(live_socket_id), do: broadcast_disconnect(live_socket_id) defp broadcast_disconnect(live_socket_id) do case get_endpoint() do {:ok, endpoint} -> try do endpoint.broadcast(live_socket_id, "disconnect", %{}) rescue error -> Logger.warning("[PhoenixKit] Failed to broadcast disconnect: #{inspect(error)}") end {:error, reason} -> Logger.warning("[PhoenixKit] Could not find parent endpoint for broadcast: #{reason}") end end def get_endpoint do if Code.ensure_loaded?(PhoenixKitWeb.Endpoint) and function_exported?(PhoenixKitWeb.Endpoint, :broadcast, 3) do {:ok, PhoenixKitWeb.Endpoint} else {:error, "No endpoint found"} end end end