From c9db8be984df82f5a98a40871f9b4cd5e1ca8a2b Mon Sep 17 00:00:00 2001 From: Max Don Date: Mon, 18 May 2026 22:36:58 +0300 Subject: [PATCH 1/9] Fix: thread workspace url_prefix into publishing dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publishing dispatch override prepends its internal-prefix + discriminator segments to `conn.path_info` before letting Phoenix re-match. When the workspace mounts at a non-root path (the default `/phoenix_kit`), the prepended segments landed at the head of path_info while the registered internal routes are scoped UNDER the workspace prefix — so the rewritten path didn't match anything and every public publishing URL returned 404. Pass `url_prefix` through to `maybe_rewrite/2` so it can strip the prefix from path_info before the group-candidate check (otherwise `phoenix_kit` was mistaken for a language code) and re-prepend it to the rewritten path so it matches the registered routes. --- lib/phoenix_kit_web/integration.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index d845f701..30994cf2 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -1293,10 +1293,15 @@ defmodule PhoenixKitWeb.Integration do # Override Phoenix.Router's call/2 (defoverridable from `use Phoenix.Router`). # Path-rewrites publishing-bound URLs before super() runs the matcher. + # The workspace's url_prefix is threaded in so the dispatch keeps + # working when PhoenixKit is mounted under a non-root path (e.g. + # `/phoenix_kit`) — without this, the dispatch's prepended segments + # land at the head of path_info instead of after the prefix, and + # nothing matches the registered internal routes. # See PhoenixKitPublishing.RouterDispatch for the rationale. def call(conn, opts) do conn = - case PhoenixKitPublishing.RouterDispatch.maybe_rewrite(conn) do + case PhoenixKitPublishing.RouterDispatch.maybe_rewrite(conn, unquote(url_prefix)) do {:rewrite, rewritten} -> rewritten :pass -> conn end From 2e8120527b4bf6d6c8ef56867d971d2092dabcec Mon Sep 17 00:00:00 2001 From: Max Don Date: Mon, 18 May 2026 23:09:34 +0300 Subject: [PATCH 2/9] Forward phoenix_kit_publishing_translations through app_layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `<.app_layout>` is a function component, so it only sees explicitly- declared attrs — conn assigns that aren't passed in are invisible to both the wrapper and the host's `Layouts.app` it forwards to. Publishing exposes a per-translation URL list as the conn assign `:phoenix_kit_publishing_translations` so host apps can build their own language switcher in their root or app layout with correct per-language URLs (important for posts with language-specific slugs that simple locale-prefix-rewrite gets wrong). The conn assign reached `root.html.heex` (which has full conn.assigns access) but NOT the inner `Layouts.app` function component called via `apply(module, function, [assigns])` — the assigns map there only contains what LayoutWrapper declared. Add an explicit `attr :phoenix_kit_publishing_translations` so when publishing's templates pass it through (separate commit on the publishing side), it lands in the assigns forwarded to the host's `Layouts.app`. --- lib/phoenix_kit_web/components/layout_wrapper.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index ada2aad9..c48ae21b 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -84,6 +84,11 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do attr :from_layout, :boolean, default: false attr :pk_pending_invitations, :list, default: [] + attr :phoenix_kit_publishing_translations, :any, + default: nil, + doc: + "Per-translation URL list exposed by `phoenix_kit_publishing` for host layouts that build their own language switcher. Plain conn assigns don't reach a function-component layout — only declared attrs do — so this gets forwarded through `prepare_parent_layout_assigns/1` to the host's `Layouts.app`." + slot :inner_block, required: false def app_layout(assigns) do From 2b4cb727157e7c58d69d6c0cb3cf408485163ba0 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 19 May 2026 00:04:45 +0300 Subject: [PATCH 3/9] Generalize LayoutWrapper host-attr forwarding via `:module_assigns` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the publishing-specific `:phoenix_kit_publishing_translations` attr on `app_layout/1` with a generic `:module_assigns` map attr. Each key in the map is merged into the top-level assigns before the host's `Layouts.app` renders, so a host's custom layout can read e.g. `assigns[:phoenix_kit_publishing_translations]` from publishing — or any future module's host-consumable key — without core having to declare each one explicitly. Phoenix function-component attrs must be declared in advance and function-component layouts see ONLY declared attrs (not surrounding `conn.assigns`). The single map attribute lets each external module thread its own host-consumable keys through the boundary without core touching the API every time a new module ships a host integration. Merge policy: existing top-level assigns win over module-supplied keys, so a module can't accidentally clobber `:flash`, `:current_user`, or any other core-managed assign by colliding on the name. Cross-repo: phoenix_kit_publishing's html.ex render branches switch in the matching commit. The boundary test in `phoenix_kit_publishing/test/.../language_switcher_exposure_test.exs` continues to pin the chain end-to-end. --- .../components/layout_wrapper.ex | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index c48ae21b..1aad6dff 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -84,10 +84,10 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do attr :from_layout, :boolean, default: false attr :pk_pending_invitations, :list, default: [] - attr :phoenix_kit_publishing_translations, :any, - default: nil, + attr :module_assigns, :map, + default: %{}, doc: - "Per-translation URL list exposed by `phoenix_kit_publishing` for host layouts that build their own language switcher. Plain conn assigns don't reach a function-component layout — only declared attrs do — so this gets forwarded through `prepare_parent_layout_assigns/1` to the host's `Layouts.app`." + "Module-supplied host-consumable assigns. Each key in this map is merged into the assigns set passed to the parent layout (`Layouts.app`), so a host's custom layout can read e.g. `assigns[:phoenix_kit_publishing_translations]` from publishing, or any other module-defined key. Plain `conn.assigns` don't reach a function-component layout — only declared attrs do — so this single map attribute is how modules thread arbitrary host-consumable data through the boundary without core having to declare each one explicitly." slot :inner_block, required: false @@ -753,6 +753,18 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do # Prepare assigns for parent layout compatibility defp prepare_parent_layout_assigns(assigns) do + # Flatten `:module_assigns` into the top-level assigns map FIRST so that + # host layouts can read module-supplied keys directly (e.g. + # `assigns[:phoenix_kit_publishing_translations]`). Existing top-level + # keys win over module-supplied ones to prevent a module from + # overwriting core-managed assigns like `:flash` or `:current_user`. + module_assigns = assigns[:module_assigns] || %{} + + assigns = + Enum.reduce(module_assigns, assigns, fn {key, value}, acc -> + Map.put_new(acc, key, value) + end) + assigns |> Map.put_new(:current_user, get_current_user_for_parent(assigns)) |> Map.put_new(:phoenix_kit_integrated, true) From fc19e786e9e1a864a1883bf8adeab69a196c5c64 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 19 May 2026 01:44:30 +0300 Subject: [PATCH 4/9] Strip the primary-locale prefix from admin URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the non-admin behaviour `Routes.path/2` already has: the primary language renders prefixless (`/phoenix_kit/admin/users`); every other locale keeps its segment (`/phoenix_kit/de/admin/users`). Both URL shapes resolve at the router level — the admin route macros declare `/:locale/admin/*` AND `/admin/*` scopes, so emitting prefixless for primary is safe and there's no redirect between the two shapes (typing `/phoenix_kit/en/admin/users` still works). Two helpers needed the same fix: the public `Routes.admin_path/2` API (used by `<.language_switcher_dropdown>`'s admin-path branch and by host code) and the private `build_admin_path/3` that handles `path/2`'s internal admin dispatch. Known tradeoff: switching locales inside admin via the UI today still crosses the `:phoenix_kit_admin_locale` ↔ `:phoenix_kit_admin` live_session boundary and forces a full-page reload. That's a separate piece of work (TODO 1 in `dev_docs/primary_language_no_prefix_plan.md`) covering router-macro restructure + a LiveView locale-rehydration hook + language-switcher push_patch refactor. Plan + Gemini/Codex review notes: dev_docs/primary_language_no_prefix_plan.md. --- lib/phoenix_kit/utils/routes.ex | 46 ++++++++++++++++++------ test/phoenix_kit/utils/routes_test.exs | 50 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 test/phoenix_kit/utils/routes_test.exs diff --git a/lib/phoenix_kit/utils/routes.ex b/lib/phoenix_kit/utils/routes.ex index b9d4266e..a6453e3e 100644 --- a/lib/phoenix_kit/utils/routes.ex +++ b/lib/phoenix_kit/utils/routes.ex @@ -85,13 +85,26 @@ defmodule PhoenixKit.Utils.Routes do # Check if a path is an admin path. defp admin_path?(url_path), do: String.starts_with?(url_path, "/admin") - # Admin paths ALWAYS include locale (even default locale) to match the - # :phoenix_kit_admin_locale live_session scope (/:locale/admin/*). - # This prevents live_session boundary crossings that cause full-page reloads. + # Admin paths drop the locale segment for the primary language, matching + # the non-admin behaviour in `build_localized_path/3`. The two emission + # shapes both have routes in the table (the admin route macros declare + # both `/:locale/admin/*` and `/admin/*` scopes), so emitting prefixless + # for the primary locale is safe. + # + # Known tradeoff: switching locales inside admin via the UI today crosses + # the `:phoenix_kit_admin_locale` ↔ `:phoenix_kit_admin` live_session + # boundary and forces a full-page reload. Unifying those sessions so + # locale-switching stays inside the WebSocket is tracked separately — + # see `dev_docs/primary_language_no_prefix_plan.md` (TODO 1). defp build_admin_path(base_path, url_path, :none), do: "#{base_path}#{url_path}" - defp build_admin_path(base_path, url_path, locale) when is_binary(locale), - do: "#{base_path}/#{locale}#{url_path}" + defp build_admin_path(base_path, url_path, locale) when is_binary(locale) do + if default_locale?(locale) do + "#{base_path}#{url_path}" + else + "#{base_path}/#{locale}#{url_path}" + end + end defp build_admin_path(base_path, url_path, _), do: "#{base_path}#{url_path}" @@ -143,17 +156,25 @@ defmodule PhoenixKit.Utils.Routes do end @doc """ - Returns a locale-prefixed admin path, bypassing the reserved-path - locale stripping that `path/2` applies. + Returns a locale-aware admin path. Strips the locale segment for the + primary language (mirroring `path/2`'s non-admin behaviour); keeps the + segment for every other locale. - Admin routes use a `/:locale/admin/*` scope, so they need locale - in the URL even though `/admin` is a reserved prefix. + Both URL shapes resolve at the router level — the admin route macros + declare `/:locale/admin/*` AND `/admin/*` scopes — so emitting prefixless + for primary is safe. Locale switching across the two shapes today still + crosses a `live_session` boundary and reloads; see + `dev_docs/primary_language_no_prefix_plan.md` (TODO 1) for the unification + work. ## Examples iex> Routes.admin_path("/admin/users", "uk") "/phoenix_kit/uk/admin/users" + iex> Routes.admin_path("/admin/users", "en") + "/phoenix_kit/admin/users" + iex> Routes.admin_path("/admin/users", nil) "/phoenix_kit/admin/users" @@ -161,7 +182,12 @@ defmodule PhoenixKit.Utils.Routes do def admin_path(url_path, locale) when is_binary(locale) do url_prefix = Config.get_url_prefix() base_prefix = if url_prefix == "/", do: "", else: url_prefix - "#{base_prefix}/#{locale}#{url_path}" + + if default_locale?(locale) do + "#{base_prefix}#{url_path}" + else + "#{base_prefix}/#{locale}#{url_path}" + end end def admin_path(url_path, _locale), do: path(url_path) diff --git a/test/phoenix_kit/utils/routes_test.exs b/test/phoenix_kit/utils/routes_test.exs new file mode 100644 index 00000000..fd586378 --- /dev/null +++ b/test/phoenix_kit/utils/routes_test.exs @@ -0,0 +1,50 @@ +defmodule PhoenixKit.Utils.RoutesTest do + use ExUnit.Case + + alias PhoenixKit.Utils.Routes + + # All assertions below assume the default language resolves to "en". + # `Routes.get_default_language_base/0` rescues any DB lookup failure and + # falls back to "en", so these tests run without a connected database. + # If the test repo is ever wired up, the default still resolves to "en" + # unless a different language is explicitly configured as default. + + describe "admin_path/2 — primary-language prefix stripping" do + test "primary locale (en) emits no prefix" do + assert Routes.admin_path("/admin/users", "en") == "/phoenix_kit/admin/users" + end + + test "non-primary locale keeps its prefix" do + assert Routes.admin_path("/admin/users", "de") == "/phoenix_kit/de/admin/users" + assert Routes.admin_path("/admin/users", "fr") == "/phoenix_kit/fr/admin/users" + end + + test "nil locale falls back to no prefix (no locale segment)" do + # When no locale is supplied, admin_path delegates to `path/1`. With a + # primary-locale fallback that's "en", the result is unprefixed. + assert Routes.admin_path("/admin/users", nil) == "/phoenix_kit/admin/users" + end + + test "nested admin paths follow the same rule" do + assert Routes.admin_path("/admin/users/edit/123", "en") == + "/phoenix_kit/admin/users/edit/123" + + assert Routes.admin_path("/admin/users/edit/123", "de") == + "/phoenix_kit/de/admin/users/edit/123" + end + end + + describe "path/2 — non-admin routes (regression coverage)" do + test "primary locale emits no prefix (unchanged behaviour)" do + assert Routes.path("/users/log-in", locale: "en") == "/phoenix_kit/users/log-in" + end + + test "non-primary locale keeps its prefix" do + assert Routes.path("/users/log-in", locale: "de") == "/phoenix_kit/de/users/log-in" + end + + test ":none locale opt skips the prefix entirely" do + assert Routes.path("/users/log-in", locale: :none) == "/phoenix_kit/users/log-in" + end + end +end From 4c71edbc8f4006c935dcb8b80143592889228d3e Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 19 May 2026 04:16:36 +0300 Subject: [PATCH 5/9] Drop session-locale fallback (URL is authoritative) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure URL→default locale resolution. The previous fallback chain (URL → session → default) became a sticky-locale trap after `Routes.path/2` was changed to emit prefixless URLs for the primary language: visiting `/foo/et/...` once stashed `"et"` in the session, then every primary-prefixless URL inherited Estonian forever — even after the user explicitly switched back. Refreshing didn't fix it because the session still held `et`. Changes: - `mount_phoenix_kit_current_scope/3` (line ~781) — drop the `session_locale ||` step and the `Process.get(...)` fallback; resolution is now URL `:locale` param → default. - `maybe_update_locale_from_params/2` (the "no locale in params" branch, line ~735) — drop the reserved-paths special case that preserved the session locale on prefixless admin/api paths. All prefixless paths now snap Gettext + assigns to the default locale, matching what `Routes.path/2` emits. - `renew_session/1` (line ~138) — drop the locale-preservation dance across session renewal; nothing reads the key anymore. - HTTP plug paths in `process_valid_locale/2` (line ~1869) and the user-preference-resolution branch (line ~1763) no longer write `:phoenix_kit_locale_base` to the session; it had no remaining consumers. Logged-in users still get their persisted preference via `user.custom_fields["preferred_locale"]`, which is independent of session and untouched by this change. Verified in Chrome: - `/phoenix_kit/et/users/log-in` renders "Logi sisse" (Estonian) - `/phoenix_kit/users/log-in` renders "Log in" / "Sign in" (English) - No need to clear cookies; refresh stays on whatever locale the URL implies. --- lib/phoenix_kit_web/users/auth.ex | 77 +++++++++++++------------------ 1 file changed, 32 insertions(+), 45 deletions(-) diff --git a/lib/phoenix_kit_web/users/auth.ex b/lib/phoenix_kit_web/users/auth.ex index 0e6b83dc..8af735e7 100644 --- a/lib/phoenix_kit_web/users/auth.ex +++ b/lib/phoenix_kit_web/users/auth.ex @@ -133,20 +133,16 @@ defmodule PhoenixKitWeb.Users.Auth do end # This function renews the session ID and erases the whole - # session to avoid fixation attacks. See renew_session/1 below - # which preserves locale preference across session renewal. + # 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 - # Preserve locale preference across session renewal - locale_base = get_session(conn, :phoenix_kit_locale_base) - delete_csrf_token() conn |> configure_session(renew: true) |> clear_session() - |> then(fn conn -> - if locale_base, do: put_session(conn, :phoenix_kit_locale_base, locale_base), else: conn - end) end @doc """ @@ -732,41 +728,31 @@ defmodule PhoenixKitWeb.Users.Auth do end end - # No locale in params - could be: - # 1. Default language URL (clean URL without prefix) - should use default locale - # 2. Reserved path (admin, api, etc.) - should preserve user's session preference + # 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 - url_path = socket.assigns[:url_path] || "" - - # Check if we're on a reserved path (admin, api, etc.) - # These paths never have locale prefix, so we should preserve user's preference - reserved_prefixes = ~w(/admin /api /webhooks /assets /static /files /images) - is_reserved_path = Enum.any?(reserved_prefixes, &String.contains?(url_path, &1)) + default_base = Routes.get_default_admin_locale() + current_base = socket.assigns[:current_locale_base] - if is_reserved_path do - # Preserve existing locale from session - don't reset to default - # The locale was already set correctly in mount_phoenix_kit_current_scope + if current_base == default_base do socket else - # Normal frontend path without locale prefix = default language URL - default_base = Routes.get_default_admin_locale() - current_base = socket.assigns[:current_locale_base] - - # If we're already on the default locale, no need to update - if current_base == default_base do - socket - else - # URL has no locale prefix, so we're navigating to default language - user = socket.assigns[:phoenix_kit_current_user] - default_dialect = DialectMapper.resolve_dialect(default_base, user) + user = socket.assigns[:phoenix_kit_current_user] + default_dialect = DialectMapper.resolve_dialect(default_base, user) - Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) - Gettext.put_locale(default_dialect) + 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 + socket + |> Phoenix.Component.assign(:current_locale_base, default_base) + |> Phoenix.Component.assign(:current_locale, default_dialect) end end @@ -801,10 +787,15 @@ defmodule PhoenixKitWeb.Users.Auth do user = socket.assigns.phoenix_kit_current_user scope = Scope.for_user(user) - # Get locale from params (URL path) first, then session, then defaults - # This ensures locale from URL takes precedence during initial mount - session_locale = session["phoenix_kit_locale_base"] - + # 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 != "" -> @@ -813,8 +804,6 @@ defmodule PhoenixKitWeb.Users.Auth do _ -> nil end || - session_locale || - Process.get(:phoenix_kit_current_locale_base) || Routes.get_default_admin_locale() current_locale = DialectMapper.resolve_dialect(current_locale_base, user) @@ -1772,7 +1761,6 @@ defmodule PhoenixKitWeb.Users.Auth do conn |> assign(:current_locale_base, base) |> assign(:current_locale, dialect) - |> put_session(:phoenix_kit_locale_base, base) end end @@ -1879,7 +1867,6 @@ defmodule PhoenixKitWeb.Users.Auth do conn |> assign(:current_locale_base, locale) |> assign(:current_locale, full_dialect) - |> put_session(:phoenix_kit_locale_base, locale) end end From 02666b4b5df141bc6f304158e735c1e84aeb8fdb Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 19 May 2026 04:36:58 +0300 Subject: [PATCH 6/9] Extend routes_test with backcompat assertions for legacy /en/admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additional helper-level assertions covering the prefix-strip edge cases Codex flagged in review: * Explicit primary locale → prefixless (intended emission). * Explicit non-primary locale → prefixed (the same shape any legacy `/phoenix_kit/en/admin/...` URL takes — the dual-scope route emission keeps both shapes resolvable in the router). * Dialect-shaped code (e.g. "en-US") passes through unchanged. Callers normalise to base first via DialectMapper, but pinning this so a future "smart-strip" refactor doesn't accidentally treat "en-US" like "en" and drop the prefix. The full router-level backcompat assertion (live HTTP probe of `/phoenix_kit/en/admin/...` returning 200) would need an integration test endpoint; deferred. Helper-level pins are the cheap insurance. --- test/phoenix_kit/utils/routes_test.exs | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/phoenix_kit/utils/routes_test.exs b/test/phoenix_kit/utils/routes_test.exs index fd586378..1228b0e3 100644 --- a/test/phoenix_kit/utils/routes_test.exs +++ b/test/phoenix_kit/utils/routes_test.exs @@ -47,4 +47,32 @@ defmodule PhoenixKit.Utils.RoutesTest do assert Routes.path("/users/log-in", locale: :none) == "/phoenix_kit/users/log-in" end end + + describe "admin_path/2 — backcompat for legacy /en/admin URLs" do + # The prefixed shape is no longer EMITTED for the primary language, + # but it must still RESOLVE (the dual-scope admin route emission + # declares both `/:locale/admin/*` and `/admin/*`). Anyone with a + # bookmark or external link pointing at `/phoenix_kit/en/admin/...` + # should still reach the page. This test pins the helper-side half + # of that contract: explicitly passing the primary locale produces + # the prefixless shape (intended), while a non-primary locale still + # produces the prefixed shape (which legacy `/en/admin/...` URLs + # also match against in the route table). + test "explicit primary locale → prefixless (intended emission)" do + assert Routes.admin_path("/admin/users", "en") == "/phoenix_kit/admin/users" + end + + test "explicit non-primary locale → prefixed (also the shape a legacy /en/ URL takes)" do + assert Routes.admin_path("/admin/users", "de") == "/phoenix_kit/de/admin/users" + end + + test "dialect-shaped locale code keeps its prefix verbatim" do + # Full dialect codes (e.g. en-US) shouldn't ever reach the helper — + # callers normalise to base via DialectMapper first — but if they + # do, the helper passes them through unchanged. Pinning this so a + # future "smart-strip" refactor doesn't accidentally treat + # "en-US" the same as "en" and drop the prefix. + assert Routes.admin_path("/admin/users", "en-US") == "/phoenix_kit/en-US/admin/users" + end + end end From 304a3468295e2b5e8a6814258496ff7f45ca97e2 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 19 May 2026 07:17:53 +0300 Subject: [PATCH 7/9] Drop preferred_locale from HTTP plug too (match LV mount) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the plug-vs-LV asymmetry Codex flagged: the HTTP plug used to consult `user.custom_fields["preferred_locale"]` when the URL had no `:locale` segment, while the LV mount path (changed in `fecf3d3e`) went straight to default. A logged-in user with preferred_locale=et visiting `/phoenix_kit/admin/foo` would see Estonian for the initial HTTP response, then English after the LV mount snapped to default — visible content flash. Pure URL → default now holds across both paths. The preference field is still WRITTEN by the language-switcher click handler (`save_user_locale_preference/2`) so the data is preserved for any future feature that wants to re-enable preference reading; it just isn't read for routing today. Also removes the now-dead `get_user_preferred_locale/1` helper. Verified in Chrome: - `/phoenix_kit/et/users/log-in` → "Logi sisse" (Estonian) - `/phoenix_kit/users/log-in` → "Log in" / "Sign in" (English) - No flash on either route. --- lib/phoenix_kit_web/users/auth.ex | 54 ++++++++++--------------------- 1 file changed, 17 insertions(+), 37 deletions(-) diff --git a/lib/phoenix_kit_web/users/auth.ex b/lib/phoenix_kit_web/users/auth.ex index 8af735e7..2367cf12 100644 --- a/lib/phoenix_kit_web/users/auth.ex +++ b/lib/phoenix_kit_web/users/auth.ex @@ -1738,29 +1738,27 @@ defmodule PhoenixKitWeb.Users.Auth do end _ -> - # No locale in URL - check user's preferred locale first, then fall back to default - # This supports admin paths where locale can't be in URL but user has a preference + # 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. current_user = get_user_for_locale_resolution(conn) + default_base = Routes.get_default_admin_locale() + default_dialect = DialectMapper.resolve_dialect(default_base, current_user) - {base, dialect} = - case get_user_preferred_locale(current_user) do - {preferred_base, preferred_dialect} when is_binary(preferred_base) -> - # User has a valid preferred locale - use it - {preferred_base, preferred_dialect} - - _ -> - # No user preference - use default language - default_base = Routes.get_default_admin_locale() - default_dialect = DialectMapper.resolve_dialect(default_base, current_user) - {default_base, default_dialect} - end - - Gettext.put_locale(PhoenixKitWeb.Gettext, dialect) - Gettext.put_locale(dialect) + Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) + Gettext.put_locale(default_dialect) conn - |> assign(:current_locale_base, base) - |> assign(:current_locale, dialect) + |> assign(:current_locale_base, default_base) + |> assign(:current_locale, default_dialect) end end @@ -1811,24 +1809,6 @@ defmodule PhoenixKitWeb.Users.Auth do end end - # Get user's preferred locale if set and valid - # Returns {base_code, full_dialect} tuple or nil if not set/invalid - defp get_user_preferred_locale(nil), do: nil - - defp get_user_preferred_locale(%{custom_fields: %{"preferred_locale" => preferred}}) - when is_binary(preferred) and preferred != "" do - base = DialectMapper.extract_base(preferred) - - # Verify the preferred locale is a valid enabled language - if DialectMapper.valid_base_code?(base) and language_enabled?(base) do - {base, preferred} - else - nil - end - end - - defp get_user_preferred_locale(_user), do: nil - defp locale_allowed?(base_code) do language_enabled?(base_code) end From 0227d7304409288280f42ee1222b02f0756421de Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 19 May 2026 07:30:25 +0300 Subject: [PATCH 8/9] Stop reading preferred_locale for dialect resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the dialect-preference leak Codex flagged: even though the plug + LV mount were both URL-driven for the BASE locale, all six `DialectMapper.resolve_dialect/2` callers in `phoenix_kit_web/users/auth.ex` still passed the current user, which let `DialectMapper`'s preferred-locale clause upgrade the URL-derived base (e.g. "en") to a user-preferred dialect ("en-GB") for logged-in users. Effect under the old code: a user with `preferred_locale="en-GB"` visiting `/phoenix_kit/admin/foo` (prefixless, primary base = "en") got dialect "en-GB" via the dialect resolver — contradicting the "URL is the only source of truth for routing" semantic the boss asked for. Fix: all six routing-path calls drop the user arg and use the arity-1 `DialectMapper.resolve_dialect/1` (default mapping, no preference upgrade). The dialect helper itself is unchanged — code that legitimately wants user-preferred dialect (none in the routing chain today) can still call the arity-2 form explicitly. Cleanup: `get_user_for_locale_resolution/1` had no remaining callers after the user args were removed; deleted. Stale-comment fixes Codex flagged: - `routes.ex:40`: the "admin paths ALWAYS get locale prefix" comment no longer matched `build_admin_path/3`'s primary-prefixless behaviour. - `auth.ex:1846`: matching "admin paths keep the locale in the URL" comment in `process_valid_locale/2` updated to explain WHY the default-locale-redirect skips admin paths today (don't canonicalise across the still-split `:phoenix_kit_admin_locale` ↔ `:phoenix_kit_admin` live_session boundary). - `auth.ex:1856`: matching wording on `admin_request?/1` updated. `preferred_locale` is now WRITTEN (by the switcher hook) but read by nothing in the routing chain. The field is preserved for future features but functionally inert today. --- lib/phoenix_kit/utils/routes.ex | 6 ++- lib/phoenix_kit_web/users/auth.ex | 71 ++++++++++++++++--------------- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/lib/phoenix_kit/utils/routes.ex b/lib/phoenix_kit/utils/routes.ex index a6453e3e..16caa67c 100644 --- a/lib/phoenix_kit/utils/routes.ex +++ b/lib/phoenix_kit/utils/routes.ex @@ -37,8 +37,10 @@ defmodule PhoenixKit.Utils.Routes do base_path = if url_prefix === "/", do: "", else: url_prefix cond do - # Admin paths ALWAYS get locale prefix to stay within the - # :phoenix_kit_admin_locale live_session and avoid full-page reloads. + # Admin paths follow the same primary-prefixless rule as non-admin + # paths (see `build_admin_path/3`). The dual-scope router emission + # keeps `/phoenix_kit/admin/*` AND `/phoenix_kit/:locale/admin/*` + # both reachable, so the emitted shape is purely cosmetic. admin_path?(url_path) -> locale = resolve_locale(opts) build_admin_path(base_path, url_path, locale) diff --git a/lib/phoenix_kit_web/users/auth.ex b/lib/phoenix_kit_web/users/auth.ex index 2367cf12..8095af50 100644 --- a/lib/phoenix_kit_web/users/auth.ex +++ b/lib/phoenix_kit_web/users/auth.ex @@ -709,8 +709,13 @@ defmodule PhoenixKitWeb.Users.Auth do current_base = socket.assigns[:current_locale_base] if current_base != locale and DialectMapper.valid_base_code?(locale) do - user = socket.assigns[:phoenix_kit_current_user] - full_dialect = DialectMapper.resolve_dialect(locale, user) + # URL-driven dialect: don't pass the user — `DialectMapper.resolve_dialect/2` + # would otherwise upgrade base "en" → user.preferred_locale "en-GB", + # which contradicts 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 @@ -744,8 +749,9 @@ defmodule PhoenixKitWeb.Users.Auth do if current_base == default_base do socket else - user = socket.assigns[:phoenix_kit_current_user] - default_dialect = DialectMapper.resolve_dialect(default_base, user) + # 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) @@ -806,7 +812,11 @@ defmodule PhoenixKitWeb.Users.Auth do end || Routes.get_default_admin_locale() - current_locale = DialectMapper.resolve_dialect(current_locale_base, user) + # URL-driven dialect: pass no user so `resolve_dialect/2` 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). @@ -1748,10 +1758,11 @@ defmodule PhoenixKitWeb.Users.Auth do # 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. - current_user = get_user_for_locale_resolution(conn) + # routing — and that includes dialect resolution: `resolve_dialect/2` + # gets called without the user so user-preferred dialect upgrades + # (e.g. base "en" → "en-GB" via custom_fields) don't sneak back in. default_base = Routes.get_default_admin_locale() - default_dialect = DialectMapper.resolve_dialect(default_base, current_user) + default_dialect = DialectMapper.resolve_dialect(default_base) Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) Gettext.put_locale(default_dialect) @@ -1779,10 +1790,10 @@ defmodule PhoenixKitWeb.Users.Auth do path -> path end) - # Set locale before redirecting + # Set locale before redirecting. URL-driven dialect — no user (see + # `process_valid_locale/2` for the rationale). default_base = Routes.get_default_admin_locale() - current_user = get_user_for_locale_resolution(conn) - default_dialect = DialectMapper.resolve_dialect(default_base, current_user) + default_dialect = DialectMapper.resolve_dialect(default_base) Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) Gettext.put_locale(default_dialect) @@ -1793,22 +1804,6 @@ defmodule PhoenixKitWeb.Users.Auth do |> halt() end - # Helper to get user for locale resolution - # Checks conn assigns first, then tries to fetch from session token if available - defp get_user_for_locale_resolution(conn) do - case conn.assigns[:phoenix_kit_current_user] do - nil -> - # User not assigned yet, try to fetch from session token - case get_session(conn, "user_token") do - nil -> nil - token -> Auth.get_user_by_session_token(token) - end - - user -> - user - end - end - defp locale_allowed?(base_code) do language_enabled?(base_code) end @@ -1832,14 +1827,21 @@ defmodule PhoenixKitWeb.Users.Auth do # Process a validated and enabled locale. # For non-admin paths: redirect default locale to clean URL (no prefix needed). - # Admin paths ALWAYS keep the locale in the URL to stay within the - # :phoenix_kit_admin_locale live_session and avoid full-page reloads. + # 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; canonicalizing to one would force a redirect that crosses + # the still-split `:phoenix_kit_admin_locale` ↔ `:phoenix_kit_admin` + # live_session boundary (see TODO 1 in + # `dev_docs/primary_language_no_prefix_plan.md`). defp process_valid_locale(conn, locale) do if locale == Routes.get_default_admin_locale() and not admin_request?(conn) do redirect_default_locale_to_clean_url(conn, locale) else - current_user = get_user_for_locale_resolution(conn) - full_dialect = DialectMapper.resolve_dialect(locale, current_user) + # 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) @@ -1850,9 +1852,10 @@ defmodule PhoenixKitWeb.Users.Auth do end end - # Check if the request path is an admin path. - # Admin paths must keep the locale in the URL to stay within the - # :phoenix_kit_admin_locale live_session boundary. + # 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. defp admin_request?(conn) do String.contains?(conn.request_path, "/admin") end From b2204467c7e8c4be5b55c6c6d748c739cd14c0c2 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 19 May 2026 10:26:05 +0300 Subject: [PATCH 9/9] Portal RowMenu dropdown to on open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `position: fixed` on the kebab menu would be interpreted relative to the nearest containing block, not the viewport. Inside a `` on the top layer (or any ancestor with `transform`/`contain`/`filter`) this shoves the menu hundreds of pixels off-screen instead of anchoring under the trigger. Surfaced by `phoenix_kit_projects` PR #9's `PopupHostLive` — assignment edit kebabs inside the modal frame rendered far right of the viewport on the host application. Reproduced locally via the parent app's `/projects-emit-demo` route. Fix portals `[data-row-menu-content]` to `document.body` in `_open()` before measuring, restores it to its original parent (tracked at `mounted()`) in `_close()` so LiveView's morphdom patching keeps finding the element on subsequent diffs. Without restoration the menu would orphan on `` and LV would re-create it inside the row, doubling the DOM. --- priv/static/assets/phoenix_kit.js | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/priv/static/assets/phoenix_kit.js b/priv/static/assets/phoenix_kit.js index 2f5f5af3..03a22833 100644 --- a/priv/static/assets/phoenix_kit.js +++ b/priv/static/assets/phoenix_kit.js @@ -2172,6 +2172,16 @@ if (typeof window.Chart === "undefined") { this.trigger = this.el.querySelector("[data-row-menu-trigger]"); this.menu = this.el.querySelector("[data-row-menu-content]"); this.isOpen = false; + // Track where the menu element originally lives so we can restore + // it on close. We portal it to while open so `position: fixed` + // coords escape any containing block created by a `` in the + // top layer or any ancestor with `transform`/`contain`/`filter` (all + // of which establish a new fixed-positioning origin). Without the + // portal, `getBoundingClientRect()` returns viewport coords but the + // browser applies them relative to the nearest containing block, + // shoving the menu hundreds of pixels off-screen inside modals. + this._homeParent = this.menu.parentNode; + this._homeNextSibling = this.menu.nextSibling; this._onTriggerClick = (e) => { e.stopPropagation(); @@ -2208,6 +2218,16 @@ if (typeof window.Chart === "undefined") { }, _open() { + // Portal to before measuring. If the menu sits inside a + // or any ancestor that establishes a fixed-positioning + // containing block, `position: fixed` coords would be interpreted + // relative to that ancestor instead of the viewport. Moving the + // menu to makes `getBoundingClientRect()` and the resulting + // `left`/`top` values consistent. + if (this.menu.parentNode !== document.body) { + document.body.appendChild(this.menu); + } + var triggerRect = this.trigger.getBoundingClientRect(); var vw = window.innerWidth; var vh = window.innerHeight; @@ -2251,6 +2271,19 @@ if (typeof window.Chart === "undefined") { this.trigger.setAttribute("aria-expanded", "false"); document.removeEventListener("click", this._onOutsideClick, true); document.removeEventListener("keydown", this._onKeydown); + + // Restore the menu to its original location so LiveView's diff + // patching can find it on subsequent updates. Without this the + // menu would stay parented to and LV's morphdom pass would + // see a "missing" element inside the row and re-create it, + // doubling the DOM and breaking the next open. + if (this._homeParent && this.menu.parentNode !== this._homeParent) { + if (this._homeNextSibling && this._homeNextSibling.parentNode === this._homeParent) { + this._homeParent.insertBefore(this.menu, this._homeNextSibling); + } else { + this._homeParent.appendChild(this.menu); + } + } }, destroyed() {