defmodule Permit.Phoenix.LiveView do @moduledoc """ A live view module using the authorization mechanism should mix in the LiveViewAuthorization module: defmodule MyAppWeb.DocumentLive.Index use Permit.Phoenix.LiveView end which adds the LiveViewAuthorization behavior with the following callbacks to be implemented - for example: # The related schema @impl true def resource_module, do: Document # Loader function for a singular resource in appropriate actions (:show, etc.); usually a context # function. If not defined, Repo.get is used by default. @impl true def loader, do: fn id -> get_organization!(id) end # How to fetch the current user from session - for instance: @impl true def fetch_subject(socket, session) do with token when not is_nil(token) <- session["token"], %User{} = current_user <- get_user(token) do current_user else _ -> nil end end Optionally, p handle_unauthorized/2 optional callback can be implemented, returning {:cont, socket} or {:halt, socket}. The default implementation returns: {:halt, socket(socket, to: socket.view.fallback_path())} """ alias Permit.Phoenix.Types, as: PhoenixTypes alias Permit.Types alias PermitPhoenix.RecordNotFoundError import Phoenix.LiveView @callback resource_module() :: module() if :ok == Application.ensure_loaded(:permit_ecto) do @callback base_query(Types.resolution_context()) :: Ecto.Query.t() @callback finalize_query(Ecto.Query.t(), Types.resolution_context()) :: Ecto.Query.t() end @callback handle_unauthorized(Types.action_group(), PhoenixTypes.socket()) :: PhoenixTypes.hook_outcome() @callback fetch_subject(PhoenixTypes.socket(), map()) :: Types.subject() @callback authorization_module() :: Types.authorization_module() @callback preload_actions() :: list(Types.action_group()) @callback fallback_path(Types.action_group(), PhoenixTypes.socket()) :: binary() @callback except() :: list(Types.action_group()) @callback loader(Types.resolution_context()) :: Types.object() | nil @callback handle_not_found(PhoenixTypes.socket()) :: PhoenixTypes.hook_outcome() @callback id_param_name(Types.action_group(), PhoenixTypes.socket()) :: binary() @callback id_struct_field_name(Types.action_group(), PhoenixTypes.socket()) :: atom() @callback unauthorized_message(PhoenixTypes.socket(), map()) :: binary() @callback event_mapping() :: map() @optional_callbacks [ if(:ok == Application.ensure_loaded(:permit_ecto), do: {:base_query, 1} ), if(:ok == Application.ensure_loaded(:permit_ecto), do: {:finalize_query, 2} ), handle_unauthorized: 2, preload_actions: 0, fallback_path: 2, resource_module: 0, except: 0, loader: 1, id_param_name: 2, id_struct_field_name: 2, handle_not_found: 1, unauthorized_message: 2 ] |> Enum.filter(& &1) defmacro __using__(opts) do quote generated: true do import unquote(__MODULE__) if :ok == Application.ensure_loaded(:permit_ecto) do require Ecto.Query end @behaviour unquote(__MODULE__) @before_compile unquote(__MODULE__) @opts unquote(opts) @impl true def event_mapping, do: unquote(__MODULE__).event_mapping() @impl true def handle_unauthorized(action, socket) do unquote(__MODULE__).handle_unauthorized(action, socket, unquote(opts)) end @impl true def unauthorized_message(action, socket) do unquote(__MODULE__).unauthorized_message(action, socket, unquote(opts)) end @impl true def authorization_module, do: unquote(opts[:authorization_module]) || raise(":authorization_module option must be given when using LiveViewAuthorization") @impl true def handle_not_found(socket) do unquote(__MODULE__).handle_not_found(socket, unquote(opts)) end @impl true def resource_module, do: unquote(opts[:resource_module]) @impl true def preload_actions, do: (unquote(opts[:preload_actions]) || []) ++ [:show, :edit, :index, :delete] @impl true def fallback_path(action, socket) do unquote(__MODULE__).fallback_path(action, socket, unquote(opts)) end @impl true def except, do: unquote(opts[:except]) || [] if :ok == Application.ensure_loaded(:permit_ecto) do @impl true def base_query(%{ action: action, resource_module: resource_module, socket: socket, params: params }) do param = id_param_name(action, socket) field = id_struct_field_name(action, socket) case params do %{^param => id} -> resource_module |> Permit.Ecto.filter_by_field(field, id) _ -> Permit.Ecto.from(resource_module) end end @impl true def finalize_query(query, resolution_context), do: unquote(__MODULE__).finalize_query(query, resolution_context, unquote(opts)) end @impl true def id_param_name(action, socket) do unquote(__MODULE__).id_param_name(action, socket, unquote(opts)) end @impl true def id_struct_field_name(action, socket) do unquote(__MODULE__).id_struct_field_name(action, socket, unquote(opts)) end defoverridable( [ if(:ok == Application.ensure_loaded(:permit_ecto), do: {:base_query, 1} ), if(:ok == Application.ensure_loaded(:permit_ecto), do: {:finalize_query, 2} ), handle_unauthorized: 2, preload_actions: 0, fallback_path: 2, resource_module: 0, except: 0, id_param_name: 2, id_struct_field_name: 2, handle_not_found: 1, unauthorized_message: 2 ] |> Enum.filter(& &1) ) end end defmacro __before_compile__(_env) do quote do if Module.defines?(__MODULE__, {:loader, 1}) do def use_loader?, do: true else def use_loader?, do: false @impl true def loader(resolution_context) do unquote(__MODULE__).loader(resolution_context, @opts) end end end end @doc """ Returns true if inside mount/1, false otherwise. Useful for distinguishing between rendering directly via router or being in a handle_params lifecycle. For example, a handle_unauthorized/1 implementation must redirect when halting during mounting, while it needn't redirect when halting during the handle_params lifecycle. ## Example @impl true def handle_unauthorized(socket) do if mounting?(socket) do {:halt, push_redirect(socket, to: "/foo")} else {:halt, assign(socket, :unauthorized, true)} end end """ @spec mounting?(PhoenixTypes.socket()) :: boolean() def mounting?(socket) do Phoenix.LiveView.get_connect_info(socket, :uri) true rescue # Raises RuntimeError if outside mount/1 because socket_info only exists while mounting. # This allows us to distinguish between accessing directly from router or via e.g. handle_params. RuntimeError -> false end @doc false def event_mapping do %{ "create" => :create, "delete" => :delete, "edit" => :edit, "index" => :index, "new" => :new, "show" => :show, "update" => :update } end @doc false def handle_unauthorized(action, socket, opts) do case opts[:handle_unauthorized] do nil -> {:halt, socket |> put_flash(:error, socket.view.unauthorized_message(action, socket)) |> push_redirect(to: socket.view.fallback_path(action, socket))} fun when is_function(fun) -> fun.(action, socket) handle_unauthorized -> handle_unauthorized end end def unauthorized_message(action, socket, opts) do case opts[:unauthorized_message] do nil -> "You do not have permission to perform this action." fun when is_function(fun) -> fun.(action, socket) msg -> msg end end def handle_not_found(_socket, _opts) do raise RecordNotFoundError, "Expected at least one result but got none" end @doc false def fallback_path(action, socket, opts) do case opts[:fallback_path] do nil -> "/" fun when is_function(fun) -> fun.(action, socket) path -> path end end if :ok == Application.ensure_loaded(:permit_ecto) do @doc false def base_query( %{ action: action, resource_module: resource_module, socket: socket, params: params }, opts ) do param = __MODULE__.id_param_name(action, socket, opts) field = __MODULE__.id_struct_field_name(action, socket, opts) case params do %{^param => id} -> resource_module |> Permit.Ecto.filter_by_field(field, id) _ -> Permit.Ecto.from(resource_module) end end @doc false def finalize_query(query, %{}, _opts), do: query end @doc false def loader(resolution_context, opts) do case opts[:loader] do nil -> nil function -> function.(resolution_context) end end @doc false def id_param_name(action, socket, opts) do case opts[:id_param_name] do nil -> "id" param_name when is_binary(param_name) -> param_name param_name_fn when is_function(param_name_fn) -> param_name_fn.(action, socket) end end @doc false def id_struct_field_name(action, socket, opts) do case opts[:id_struct_field_name] do nil -> :id struct_field_name when is_binary(struct_field_name) -> struct_field_name struct_field_name_fn when is_function(struct_field_name_fn) -> struct_field_name_fn.(action, socket) end end end