# `PhoenixKit.Utils.Routes`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.15.1/lib/phoenix_kit/utils/routes.ex#L1)

Utility functions for working with PhoenixKit routes and URLs.

This module provides helpers for constructing URLs with the correct
PhoenixKit prefix configured in the application.

# `request_context`

```elixir
@type request_context() :: Plug.Conn.t() | Phoenix.LiveView.Socket.t() | nil
```

The request a redirect is being built for. The application's router is read
from it, which is how `routable?/2` works. `nil` is accepted — it simply
makes every candidate fail closed, so the chain lands on a core-owned page.

# `admin_area_path?`

```elixir
@spec admin_area_path?(term()) :: boolean()
```

Whether a REAL URL lands in PhoenixKit's admin area.

Not the same question as the argument to `path/1` answers. `path/1` takes the
canonical, unprefixed, unlocalized shape (`"/admin/users"`); this takes the
other shape — a value an operator typed into a setting, a `?return_to=`, an
HTTP referer — already wearing the host's mount prefix and, on a multilingual
site, a locale segment (`/phoenix_kit/et/admin/users`). So the path is
un-built the way `path/1` builds it: drop the configured `url_prefix`, allow
one leading segment for the locale, and compare the remainder by **segment**
against `PhoenixKit.Config.get_admin_path/0`.

Two properties that matter to callers:

  * It reads the **configured** admin segment, so it keeps working on a host
    that set `config :phoenix_kit, admin_path:`. Hand-rolling
    `String.contains?(path, "/admin/")` does not, and it also claims
    `/administrators` and a host's own page at `/shop/admin`.
  * It is **over-strict on purpose**, exactly as `auth_page?/1` is: a host
    page at `/shop/admin` is reported as admin-area. Refusing one legitimate
    path is a better failure than the alternatives this guards — for core's
    own resolver, an unbounded redirect loop; for a caller allowlisting a
    client-supplied return path, an open redirect.

## For module packages

This is the supported way to ask the question. A package that allowlists a
redirect target (a `_live_referer`, a `?return_to=`) should pair it with
`local_path?/1`, which is what rejects `//evil.com`, `/\evil.com` and ASCII
control characters:

    if Routes.local_path?(path) and Routes.admin_area_path?(path) do
      path
    end

Order matters only for readability — neither implies the other.
`admin_area_path?/1` is about WHERE a path points; `local_path?/1` is about
whether it points off-site at all.

## Examples

    iex> PhoenixKit.Utils.Routes.admin_area_path?("/phoenix_kit/admin/users")
    true

    iex> PhoenixKit.Utils.Routes.admin_area_path?("/phoenix_kit/et/admin/users")
    true

    iex> PhoenixKit.Utils.Routes.admin_area_path?("/phoenix_kit/users/log-in")
    false

# `admin_path`

Returns a locale-aware admin path. For non-primary locales the locale
segment is always emitted. For the primary locale the shape follows
the site-wide `default_language_no_prefix` setting
(`Languages.default_language_no_prefix?/0`): prefixless when the
setting is on, prefixed when off.

Both URL shapes resolve at the router level — the admin route macros
declare `/:locale/admin/*` AND `/admin/*` scopes — so either shape is
routable. The two shapes share one `live_session :phoenix_kit_admin`,
so locale switching across them stays on the WebSocket
(`push_navigate`) without a full-page reload.

## Examples

    iex> Routes.admin_path("/admin/users", "uk")
    "/phoenix_kit/uk/admin/users"

    iex> Routes.admin_path("/admin/users", nil)
    "/phoenix_kit/admin/users"

Primary-locale shape depends on the `default_language_no_prefix`
setting (not shown as doctests because the result varies with
runtime state):

    # setting OFF (default)
    Routes.admin_path("/admin/users", "en") #=> "/phoenix_kit/en/admin/users"

    # setting ON
    Routes.admin_path("/admin/users", "en") #=> "/phoenix_kit/admin/users"

# `admin_segment`

```elixir
@spec admin_segment() :: String.t()
```

The configured top-level segment of the admin area — `"/admin"` by default.

See `PhoenixKit.Config.get_admin_path/0` for why `/admin` remains the name
used in code regardless of what this returns.

# `apply_admin_segment`

```elixir
@spec apply_admin_segment(String.t()) :: String.t()
```

Rewrites a canonical `/admin...` path onto the configured segment.

The **emit** half of the rename. Every URL core hands out goes through here,
via `path/2` or `admin_path/2`; the router's own route table gets the same
substitution at compile time in `PhoenixKitWeb.Integration`.

A no-op on the default configuration, and on any path that is not an admin
path — including one that has already been rewritten, so applying it twice is
safe.

## Examples

    iex> PhoenixKit.Utils.Routes.apply_admin_segment("/admin/users")
    "/admin/users"

    iex> PhoenixKit.Utils.Routes.apply_admin_segment("/users/log-in")
    "/users/log-in"

# `auth_page?`

```elixir
@spec auth_page?(term()) :: boolean()
```

Whether a path lands on one of the sign-in pages (or `/users/log-out`).

Public because the `after_login_path` / `after_registration_path` changeset
applies the same rule when the setting is saved — one list, one predicate, so
a new auth route can't be guarded on read and forgotten on write.

Suffix-matched, since the real URL carries the host's mount prefix and an
optional locale segment (`/app/et/users/log-in`). A host page whose own path
happens to end in one of these segments is refused too — over-strict rather
than allowing a redirect loop.

## Examples

    iex> PhoenixKit.Utils.Routes.auth_page?("/users/log-in")
    true
    iex> PhoenixKit.Utils.Routes.auth_page?("/et/users/log-out/")
    true
    iex> PhoenixKit.Utils.Routes.auth_page?("/dashboard")
    false

# `base_url`

```elixir
@spec base_url() :: String.t()
```

The bare base URL (scheme + host, no trailing slash), same source `url/1` uses.

For absolutizing a path that is **already** url-prefixed and locale-prefixed
(e.g. a notification's `link`, built via `path/1`) — concatenate it onto this
directly. Do NOT pass such a path to `url/1`, which re-applies `path/1` and
would double-prefix it.

# `canonical_admin_path`

```elixir
@spec canonical_admin_path(String.t()) :: String.t()
```

Rewrites a real `/<configured segment>...` path back to canonical `/admin...`.

The **read** half of the rename, and the exact inverse of
`apply_admin_segment/1`. Anything that compares an incoming request path
against a path written in code — tab active state, the admin nav's parser,
the language switcher — canonicalises first so the comparison is between two
values spelled the same way.

Expects the URL prefix and any locale segment to have been stripped already;
it only looks at the leading segment.

## Examples

    iex> PhoenixKit.Utils.Routes.canonical_admin_path("/admin/users")
    "/admin/users"

# `get_default_admin_locale`

Returns the default locale (base code) from the Languages module.

Extracts the base code from the default language (e.g., "en-US" becomes "en").
Falls back to "en" if no default language is configured.

## Examples

    iex> Routes.get_default_admin_locale()
    "en"

# `local_path?`

```elixir
@spec local_path?(term()) :: boolean()
```

Returns `true` when `path` is safe to use as a local redirect / `return_to`
target: a binary that begins with a single `/` but not `//` or `/\` — both of
which browsers resolve as protocol-relative, host-switching URLs. Use this to
guard user-supplied redirect params against open-redirect attacks.

ASCII control characters are rejected too. Browsers strip tab/CR/LF while
parsing a URL, so `"/\t/evil.example"` (from `?return_to=%2F%09%2Fevil.example`)
becomes `//evil.example` — a cross-origin navigation — once it reaches
`window.location`. `Phoenix.Controller.redirect/2` blocks those itself, but
LiveView's `validate_local_url!` only rejects `\\` and a leading `//`, so a
LiveView `redirect(socket, to: ...)` would otherwise pass it through.

## Examples

    iex> PhoenixKit.Utils.Routes.local_path?("/admin/dashboard")
    true
    iex> PhoenixKit.Utils.Routes.local_path?("//evil.com")
    false
    iex> PhoenixKit.Utils.Routes.local_path?("https://evil.com")
    false
    iex> PhoenixKit.Utils.Routes.local_path?("/\t/evil.com")
    false

# `locale_aware_path`

Returns a locale-aware path using locale from assigns.

This function is specifically designed for use in component templates
where the locale needs to be passed explicitly via assigns.

Prefers base locale code for URL generation (current_locale_base),
falls back to extracting base from full dialect code (current_locale).

# `locale_aware_user_settings_path`

```elixir
@spec locale_aware_user_settings_path(map()) :: String.t()
```

`user_settings_path/1` with the locale taken from `assigns`.

Same relationship to `user_settings_path/1` as `locale_aware_path/2` has to
`path/2` — for templates that hold the current locale in assigns rather than
a keyword list.

# `locale_switch_path`

```elixir
@spec locale_switch_path(String.t() | nil, String.t(), keyword()) :: String.t()
```

The current page's path, under a different locale.

What the language switcher links to. Strips whatever locale segment the path
already carries and rebuilds it for `locale`, so switching twice cannot stack
prefixes.

## The bug this replaces

Three near-copies of this lived in `AdminNav`, `LayoutWrapper` and
`UserDashboardNav`, and each decided differently which leading segment counted
as a locale worth removing:

  * two of them tested membership of an *enabled-codes* list, while the
    dropdown beside them was populated from `get_display_languages/0` — a
    strictly larger set. Any language the switcher offered but the module had
    not enabled was therefore not recognised on the way back out: from
    `/phoenix_kit/ja/admin`, switching to English produced
    `/phoenix_kit/en/ja/admin`, which routes nowhere.
  * the third matched by shape (a 2-character segment, or 5 with a dash),
    which strips `ja` correctly but would also eat an unrelated two-letter
    path segment and misses 3-letter codes entirely.

The set used here is the one the switcher itself can emit — every display
language plus every enabled code, with base codes for both. A path the
switcher produced is therefore always recognised on the next switch, and a
segment it could never have produced (`/api`, `/id`) is left alone.

## Options

  * `:current_locale` — the locale the page is being viewed in. Included in
    the strip set, so an active locale is removed even if it has since been
    disabled.

# `main_page_path`

```elixir
@spec main_page_path() :: String.t() | nil
```

The configured site main page, or `nil` when unset or unusable.

`nil` rather than `"/"` is deliberate: an unset setting means "nobody chose",
and `safe_destination/2` probes `"/"` on its own as the *last* candidate. A
`"/"` default here would instead assert it as the administrator's
first-priority choice — ahead of everything, on a host that may not route it.

The setting is validated as a local path when saved and re-guarded here on
read, so a hand-edited DB row can't turn it into an open redirect.

# `path`

Builds a PhoenixKit path: the host's mount prefix, plus a locale segment when
the site is multilingual.

## The `:locale` option

This is the contract, and it is what a bilingual site needs when auth pages
come out in the wrong language. Without it the locale is *determined* — from
the process's Gettext locale — which is right for a link rendered inside a
request and wrong for a link built outside one (an email, a background job, a
script).

- `locale: "et"` — force this locale.
- `locale: :none` — emit no locale segment at all. For anything that is not a
  page: assets, webhooks, `sitemap.xml`.
- `locale: nil` or omitted — determine it from the current process.

### Examples

    Routes.path("/users/log-in")                 # current locale
    Routes.path("/users/log-in", locale: "et")   # /et/users/log-in
    Routes.path("/sitemap.xml", locale: :none)   # never localized

Note that the primary language is emitted prefixlessly when the site is
configured that way, so `locale: "en"` on an English-primary site yields a
path with no `/en` segment — that is deliberate, not a dropped option.

See the multilang guide for how locales are resolved and switched; the symptom
of getting this wrong (English auth pages on a translated site) reads like an
i18n bug rather than a routing one.

# `phoenix_kit_app_base`

```elixir
@spec phoenix_kit_app_base() :: String.t()
```

Gets the base module name for the parent application.

Reads from :phoenix_kit, :layouts_module config (e.g., MprojectWeb.Layouts -> MprojectWeb).

## Examples

    iex> PhoenixKit.Utils.Routes.phoenix_kit_app_base()
    "MprojectWeb"

# `post_auth_path`

```elixir
@spec post_auth_path(
  [term()],
  keyword()
) :: String.t()
```

Resolves where to send a user once they are signed in and confirmed.

Takes candidate destinations in priority order (e.g. a `?return_to=` param,
then the session's `user_return_to`) and returns the first that passes
`local_path?/1`. Falls back to the `after_login_path` setting, then to
core's own guaranteed landing, `/admin`.

The setting is validated as a local path when saved, but is re-guarded here
so a hand-edited DB row can't turn a post-auth redirect into an open
redirect. Single source of truth for the post-auth landing page — used by
the login flow (`signed_in_path/1`) and by both confirmation LiveViews.

## Options

  * `:context` — the `conn`/`socket` the redirect is being built for. **Pass
    it wherever one is at hand.** `"/"` is the host's home page and core
    declares no route for it; with a context the tail of the chain is probed
    with `routable?/2` and, when the host really has no `/`, handed to
    `safe_destination/2`, which can then pick the *best* destination for this
    subject instead of merely a safe one. Without a context the tail is
    `path("/admin")` — still safe, just less specific.
  * `:scope` — the subject, forwarded to `safe_destination/2` for that last
    step. Only consulted when a `:context` is present.

The context-less tail is `path("/admin")` and deliberately **not**
`safe_destination(nil, opts)`: with no scope to go on that call runs the
ANONYMOUS chain and terminates on `/users/log-in`, whose `on_mount` bounces
an authenticated visitor straight back through here — the "core-owned
destination" guarantee would survive exactly one hop. `/admin` is scope-blind
on purpose: core declares it unconditionally and admits every authenticated
visitor, so it cannot bounce anyone.

## Examples

    iex> PhoenixKit.Utils.Routes.post_auth_path(["/checkout"])
    "/checkout"

With nothing usable to go on the chain lands on core's guaranteed landing.
Its exact shape depends on the host's mount prefix and the language settings,
so these read as comparisons rather than literals — they asserted `"/"` back
when the tail was the host's unowned home page:

    iex> alias PhoenixKit.Utils.Routes
    iex> Routes.post_auth_path(["https://evil.com", nil]) == Routes.path("/admin")
    true

    iex> alias PhoenixKit.Utils.Routes
    iex> Routes.post_auth_path(["/users/log-out"]) == Routes.path("/admin")
    true

# `return_to_query`

```elixir
@spec return_to_query(term()) :: String.t()
```

Renders `?return_to=<path>` for a link, or `""` when there is nothing safe to
carry. Lets the auth pages hand the pending destination to each other instead
of dropping it the moment a visitor switches to another sign-in method.

# `routable?`

```elixir
@spec routable?(request_context(), term()) :: boolean()
```

Whether `path` actually resolves to a `GET` route in the application's router.

The router is taken from the request: `conn.private[:phoenix_router]`, set by
the generated router before dispatch, or `socket.router`, set when the
LiveView is mounted at the router.

**When the router cannot be determined this returns `false`.** The two failure
modes are asymmetric: emitting an unverified path is the 404 this whole
mechanism exists to eliminate, while skipping the candidate merely falls
through to a core-owned page that is guaranteed to exist.

# `safe_destination`

```elixir
@spec safe_destination(
  request_context(),
  keyword()
) :: String.t()
```

Resolves where to send a visitor that core is *allowed* to send them.

Replaces every hardcoded `"/"` / `Routes.path("/")` destination in core.
`Routes.path("/")` emits a locale-prefixed root (`/en`); the route that would
serve it belongs to the host application, which core cannot declare, so on a
host that never declared one every such redirect 404s.

## The chain

Authenticated (`opts[:scope]` passes `Scope.authenticated?/1`):

  1. `:return_to` — the untrusted explicit destination
  2. `/admin`, when `Scope.can_access_admin_area?/1` and `:skip_admin` is not set
  3. `/dashboard`
  4. the `after_login_path` setting
  5. the host's home page — `path("/")`, then `"/"`

Anonymous:

  1. the `main_page_path` setting, when set and still resolvable
  2. the host's home page — `path("/")`, then `"/"`

Every candidate must be a local path (`local_path?/1`), not an auth page
(`auth_page?/1`), **and actually routable** (`routable?/2`). Under
`:skip_admin` it must additionally not be an admin-area path
(`admin_area_path?/1`) — see the option below.

The home page is a candidate like any other, and only like any other. It is
the one destination in this whole mechanism that core cannot declare, so it
is used exactly where the host proves it declared it, and skipped silently
everywhere else. Dropping it entirely would have been a silent regression for
every already-working install, where logging out has always landed on the
site home.

Both shapes are offered, locale-prefixed first, because a multilingual host
may declare either or both — see `home_candidates/0`. The prefixed form is
what the eleven original call sites emitted; the defect was that they emitted
it *unprobed*, not that they named it.

When nothing survives, the terminal is core's own: `/admin` for any
authenticated visitor, `/users/log-in` for an anonymous one. The terminal
follows authentication rather than being a single page, because
`/users/log-in` bounces a signed-in visitor straight back out again —
terminating an authenticated chain there would hand the decision to
`post_auth_path/2`, one hop later.

## Options

  * `:scope` — `%PhoenixKit.Users.Auth.Scope{}` or `nil`. **Pass it
    explicitly.** Several call sites run on pipelines that never assign a
    scope, and full logout still carries the just-logged-out user in
    `conn.assigns` after the session has been cleared, so inferring it here
    would answer "authenticated" about someone who no longer is.
  * `:return_to` — a candidate path, or a list of them in priority order.
    Honoured on the authenticated chain only: an anonymous pending
    destination belongs in the `user_return_to` session key, not in a
    redirect.
  * `:skip_admin` — the caller is *rejecting* this visitor from the admin
    area. It suppresses step 2 **and drops every remaining candidate that
    resolves into the admin area** (`admin_area_path?/1`), whichever step
    produced it — a `:return_to`, or an `after_login_path` an operator
    pointed at `/admin/users`. Suppressing only step 2 was not enough: on a
    host with `user_dashboard_enabled: false` the setting was the first
    candidate left standing, it is routable, and handing it back re-entered
    the same gate that had just refused the visitor — a candidate always won,
    the terminal was never reached, and the browser gave up with
    `ERR_TOO_MANY_REDIRECTS`.

    The terminal is deliberately NOT filtered: it is the `/admin` index,
    which the gate admits every authenticated visitor to, so arriving there
    is a render rather than a second bounce. That asymmetry is the whole
    point — the chain has somewhere to end.

## The invariant

Every value returned is either a path the caller supplied, an administrator
configured, or one of the two shapes of the host's own home page — **each
proven to resolve in the router** — or one of the two landings core declares
itself. There is no third branch, and nothing is ever returned unprobed
except those two landings, which core declares and permits unconditionally.

`path("/")`, the locale-prefixed root that started all this, is therefore
still a candidate — it simply may no longer be *synthesized*. That distinction
is the fix: the eleven original call sites returned it without asking whether
it resolved, which is why it 404'd.

The invariant used to be "every candidate was probed, terminals included",
because core could promise nothing about its own pages: `/dashboard` is
compiled out by `user_dashboard_enabled: false`, and `/admin` used to reject
an authenticated visitor who held no admin rights. Neither is true of the
terminals any more:

  * `/admin` is declared unconditionally by the admin index route and, since
    `:phoenix_kit_ensure_admin` exempts that one view from its permission
    checks (`PhoenixKitWeb.Users.Auth.landing_view?/1`), admits every
    authenticated visitor — one who holds no rights is greeted and shown
    nothing else. So it can neither 404 nor bounce.
  * `/users/log-in` is declared unconditionally too, by the public auth
    surface. That is a **separate** fact from the `/admin` decision: it rests
    on `generate_public_live_routes/1` in `PhoenixKitWeb.Integration`, not on
    anything the admin area does, and it holds independently of it.

So the invariant is now: **the chain ends at a path core declares
unconditionally and permits unconditionally.** The terminal is still handed
to `routable?/2`, but only as a diagnostic — the arm is returned either way,
and the probe exists to name a misconfigured install in the log instead of
letting it surface as a mystery 404. See `terminal/2`.

That invariant is about the TERMINAL, and stating it was not enough to make
the chain terminate. A candidate that wins is returned instead of the
terminal, so under `:skip_admin` — the rejection path — the candidates are
held to the weaker fact the caller actually needs: **no candidate may be an
admin-area path.** Otherwise the resolver can answer with the very kind of
page the visitor was just refused, the gate refuses it again, and the
identical computation runs forever without ever reaching the terminal it was
promised. With the filter in place a `skip_admin` resolution is either a
non-admin path proven routable in this router, or the `/admin` index — and
the index admits everyone, so the next hop renders.

The authenticated terminal is not an auth page: `/users/log-in` redirects an
authenticated visitor through `post_auth_path/2`, which re-enters this
function, so using it there is an infinite redirect rather than a fallback.

# `url`

Returns a full url with preconfigured prefix.

This function first checks for a configured site URL in Settings,
then automatically detects the correct URL from the running Phoenix
application endpoint when possible, falling back to static configuration.
This ensures that magic links and other email links work correctly in both
development and production environments, with full control over the base URL
through the Settings admin panel.

# `url_prefix`

```elixir
@spec url_prefix() :: String.t()
```

Returns the configured PhoenixKit URL prefix.

## Examples

    iex> PhoenixKit.Utils.Routes.url_prefix()
    "/phoenix_kit"

# `user_settings_path`

```elixir
@spec user_settings_path(keyword()) :: String.t()
```

Where the "Settings" entry in the user menu should point.

Core's own account page, `/profile/settings`, unless an operator set
`user_settings_path` — the escape hatch for a host that wants to own the
account UI. Every user-facing link to the settings page resolves through
here, so pointing one setting at a host page moves all of them at once.

Replaces the older `/dashboard/settings`, which tied the account UI to the
user dashboard that hosts can compile out (`user_dashboard_enabled`).

The override is validated as a local path when saved and re-guarded here on
read — same `usable_candidate?/1` check as `main_page_path/0` and
`after_login_path` — so a hand-edited DB row cannot turn a menu entry into
an off-site link, or into `/users/log-out`, silently converting every
"Settings" link into a sign-out link. An override is used verbatim — it is
the host's own path, so core neither prefixes it nor inserts a locale
segment.

## Options

  * `:locale` — locale segment for the built-in path, as `path/2` takes it.
    Ignored when an override is set.

## Examples

    iex> PhoenixKit.Utils.Routes.user_settings_path()
    "/phoenix_kit/profile/settings"

---

*Consult [api-reference.md](api-reference.md) for complete listing*
