Current section
Files
Jump to
Current section
Files
lib/usher/web/router.ex
defmodule Usher.Web.Router do
@moduledoc """
Mount the Usher Dashboard into a Phoenix router.
This module provides the `usher_dashboard/2` macro, which mounts Usher's
LiveView-powered dashboard and wires it to your application's LiveView socket.
## Usage
Import `Usher.Web.Router` in your Phoenix router and call `usher_dashboard/2`
within a scope that uses your browser pipeline:
defmodule MyAppWeb.Router do
use MyAppWeb, :router
import Usher.Web.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
# ...your auth plug(s) that assign the current user...
end
scope "/" do
pipe_through :browser
# Mount at "/usher" with default options
usher_dashboard "/usher"
end
end
By default, this mounts three routes under the given path:
- `GET /usher` — Dashboard index
- `GET /usher/new` — New item view
- `GET /usher/:id/edit` — Edit item view
The exact UI and behavior are provided by Usher's LiveViews and may evolve,
but the route structure remains stable for helpers and links.
## Important
Usher does not implement authentication on its own. Until you configure an
authentication pipeline and a custom resolver (`resolver:` option), the
default `Usher.Web.Resolver` allows access for everyone (`:all`). Protect your
Usher routes behind your browser/auth pipelines and provide a resolver to
restrict access appropriately. See `Usher.Web.Authentication` and
`Usher.Web.Resolver` for details.
## Options
- `:as` — Prefix used for route helpers and the LiveView session name.
Defaults to `:usher_dashboard`. For example, with the default you can
generate paths with `~p"/usher"` or via helpers such as
`Routes.usher_dashboard_path(conn, :index)` depending on your Phoenix
version and setup.
- `:socket_path` — Path to your LiveView socket. Defaults to `"/live"`.
Change this if your application configured a custom LiveView socket path.
- `:transport` — LiveView transport, either `:websocket` (default) or
`:longpoll`. Match this to your LiveView configuration.
- `:csp_nonce_assign_key` — Configure how CSP nonces are read from
`conn.assigns` and injected into the LiveView session. Accepts:
* `nil` (default): no CSP nonces are propagated
* an atom: the same assign key is used for both script and style nonces
* a map: `%{script: :script_key, style: :style_key}` for independent keys
For Phoenix apps that place a single nonce under `:csp_nonce`, you can pass
`csp_nonce_assign_key: :csp_nonce`. If you maintain separate keys, pass
a map such as `csp_nonce_assign_key: %{script: :csp_script_nonce, style: :csp_style_nonce}`.
- `:resolver` — A module that implements the `Usher.Web.Resolver` behaviour
used to determine the current user and their access level. Defaults to
`Usher.Web.Resolver`, which allows all access. See `Usher.Web.Authentication`
for end-to-end authentication and access control details.
- `:on_mount` — A list of additional `on_mount` hooks to run for all Usher
LiveViews. Usher always prepends `Usher.Web.Authentication` and
Usher.Web.LiveMount; any hooks you provide are invoked afterwards.
## Authentication & access control
Usher's dashboard authentication flow is implemented by the
`Usher.Web.Authentication` on-mount hook together with a configurable
`Usher.Web.Resolver`. The resolver extracts a user and determines an access
level; the hook applies enforcement (including redirects for forbidden
access). See `Usher.Web.Authentication` for the complete walkthrough and a
resolver example.
## CSP Nonces
If your application enforces a strict CSP and uses per-request nonces, set
`:csp_nonce_assign_key` so Usher's LiveViews receive and apply the nonces to
inline scripts and styles as needed. Usher reads the nonces from
`conn.assigns` using the keys you provide and makes them available as
`@csp_nonces.script` and `@csp_nonces.style` assigns during mount.
## Session Data
Usher stores a small set of values in the LiveView session for each request,
all of which are internal implementation details. They are documented here to
aid integration and debugging:
- `"prefix"` — the mounted path prefix (scope-aware)
- `"live_path"` — the LiveView socket path (e.g., `"/live"`)
- `"live_transport"` — the chosen LiveView transport
- `"resolver"` — the resolver module used for auth/access
- `"user"` — the resolved user value
- `"access"` — the access level derived by the resolver
- `"csp_nonces"` — a map with `:script` and `:style` nonce values (when set)
"""
import Phoenix.Component, only: [assign: 2, assign: 3]
alias Usher.Web.Resolver
@default_opts [
socket_path: "/live",
transport: :websocket,
csp_nonce_assign_key: nil,
resolver: Usher.Web.Resolver
]
@allowed_transport_values ~w(longpoll websocket)a
@doc """
Mount the Usher Dashboard at the given `path` within a Phoenix router.
See the module documentation for a complete list of options and usage notes.
"""
defmacro usher_dashboard(path, opts \\ []) do
quote bind_quoted: binding() do
prefix = Phoenix.Router.scoped_path(__MODULE__, path)
scope path, alias: false, as: false do
import Phoenix.LiveView.Router, only: [live: 4, live_session: 3]
{session_name, session_opts, route_opts} = Usher.Web.Router.__options__(prefix, opts)
live_session session_name, session_opts do
live "/", Usher.Web.Live.InvitationsList, :index, route_opts
live "/new", Usher.Web.Live.InvitationsList, :new, route_opts
live "/:id/edit", Usher.Web.Live.InvitationsList, :edit, route_opts
end
end
end
end
def __options__(prefix, opts) do
opts = Keyword.merge(@default_opts, opts)
Enum.each(opts, &validate_opt!/1)
on_mount = Keyword.get(opts, :on_mount, [])
on_mount = [Usher.Web.Authentication, Usher.Web.LiveMount | on_mount]
session_args = [
prefix,
opts[:socket_path],
opts[:transport],
opts[:csp_nonce_assign_key],
opts[:resolver]
]
session_opts = [
on_mount: on_mount,
session: {__MODULE__, :__session__, session_args},
root_layout: {Usher.Web.Layouts, :root}
]
session_name = Keyword.get(opts, :as, :usher_dashboard)
{session_name, session_opts, as: session_name}
end
def __session__(conn, prefix, live_path, live_transport, csp_key, resolver) do
csp_keys = expand_csp_nonce_keys(csp_key)
user = Resolver.call_with_fallback(resolver, :resolve_user, [conn])
access = Resolver.call_with_fallback(resolver, :resolve_access, [user])
%{
"prefix" => prefix,
"live_path" => live_path,
"live_transport" => live_transport,
"resolver" => resolver,
"user" => user,
"access" => access,
"csp_nonces" => %{
style: conn.assigns[csp_keys[:style]],
script: conn.assigns[csp_keys[:script]]
}
}
end
def on_mount(:usher_on_mount_hook, _params, session, socket) do
%{
"prefix" => prefix,
"live_path" => live_path,
"live_transport" => live_transport,
"csp_nonces" => csp_nonces,
"resolver" => resolver,
"user" => user,
"access" => access
} = session
Process.put(:routing, {socket, prefix})
socket =
socket
|> assign(live_path: live_path, live_transport: live_transport)
|> assign(:page_title, "Usher Dashboard")
|> assign(:csp_nonces, csp_nonces)
|> assign(:resolver, resolver)
|> assign(user: user, access: access)
{:cont, socket}
end
defp expand_csp_nonce_keys(nil), do: %{style: nil, script: nil}
defp expand_csp_nonce_keys(key) when is_atom(key), do: %{style: key, script: key}
defp expand_csp_nonce_keys(map) when is_map(map), do: map
defp validate_opt!({:socket_path, path}) do
unless is_binary(path) and byte_size(path) > 0 do
raise ArgumentError, """
invalid :socket_path, expected a binary URL, got: #{inspect(path)}
"""
end
end
defp validate_opt!({:transport, transport}) do
unless transport in @allowed_transport_values do
raise ArgumentError, """
invalid :transport, expected one of #{inspect(@allowed_transport_values)},
got #{inspect(transport)}
"""
end
end
defp validate_opt!({:csp_nonce_assign_key, key}) do
unless is_nil(key) or is_atom(key) or is_map(key) do
raise ArgumentError, """
invalid :csp_nonce_assign_key, expected nil, an atom or a map with atom keys,
got #{inspect(key)}
"""
end
end
defp validate_opt!({:resolver, resolver}) do
unless is_atom(resolver) and not is_nil(resolver) do
raise ArgumentError, """
invalid :resolver, expected a module that implements the Usher.Web.Resolver behaviour,
got: #{inspect(resolver)}
"""
end
end
defp validate_opt!(invalid_opt) do
raise ArgumentError, "invalid option for usher_web: #{inspect(invalid_opt)}"
end
end