Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `put_flash(:error, socket.view.unauthorized_message(action, socket)` is always done,
- `push_navigate(socket, to: socket.view.fallback_path(action, socket))` is done if the LiveView is in the mounting phase,
- `fallback_path` defaults to the current `_live_referer` path if available, otherwise it is `/`. This means that if using the [link](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#link/1) component with `:navigate` option within the current session, we will still be able to navigate back to the currently displayed page, even though it will go through the mounting phase.
- Permit dependency bumped to 0.3.1 to fix curiosum-dev/permit#49.

## [0.3.1]

Expand Down
2 changes: 2 additions & 0 deletions lib/permit_phoenix/actions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ defmodule Permit.Phoenix.Actions do
def singular_actions do
unquote(__MODULE__).singular_actions()
end

defoverridable grouping_schema: 0, singular_actions: 0
end
end

Expand Down
54 changes: 54 additions & 0 deletions lib/permit_phoenix/decorators/live_view.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
defmodule Permit.Phoenix.Decorators.LiveView do
@moduledoc false

def __on_definition__(env, _kind, :handle_event, args, _guards, _body) do
event_name = List.first(args)
attribute_value = Module.get_last_attribute(env.module, :permit_action, nil)

# event_name must be a string - this means the event handler is defined
# using pattern matching; otherwise, we cannot infer the event name from the
# function body.

cond do
is_binary(event_name) and not is_nil(attribute_value) ->
prior_value = Module.get_attribute(env.module, :__event_mapping__, %{})

Module.put_attribute(
env.module,
:__event_mapping__,
Map.put(prior_value, event_name, attribute_value)
)

# delete the permit_action attribute to avoid mapping different event names
# to the same action.
Module.delete_attribute(env.module, :permit_action)

not is_binary(event_name) and not is_nil(attribute_value) ->
# handle_event is not defined using pattern matching, so we cannot infer the event name from the
# function body.
# In this case, the user will have to implement event_mapping/0.

msg = """
@permit_action module attribute cannot be used with handle_event/3 clauses that do not pattern match directly on the event name.

To handle events covered by this clause, please implement event_mapping/0 to map event names (strings) to Permit actions (atoms), for example:

def event_mapping do
%{
"store" => :create,
"patch" => :update
}
end

Note that, in the presence of other event handlers that pattern match on the event name, mappings defined using @permit_action take precedence over mappings defined in event_mapping/0.
"""

raise ArgumentError, msg

true ->
:ok
end
end

def __on_definition__(_env, _kind, _name, _args, _guards, _body), do: :ok
end
121 changes: 104 additions & 17 deletions lib/permit_phoenix/live_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,43 @@ defmodule Permit.Phoenix.LiveView do
## Event authorization

Actions such as updating or deleting a resource are typically implemented in LiveView using `handle_event/3`.
Permit taps into `handle_event/3` processing, loads the resource with Permit.Ecto (or a loader function) based
on the event's `"id"` param and a query based on the currently resolved permissions and puts it in `assigns`.
If authorization fails, `handle_unauthorized/2` is called.
Permit taps into `handle_event/3` processing and, depending on the event's nature:
* For events carrying an `"id"` param (e.g. record deletion from an index page), **loads the record** with
Permit.Ecto (or a loader function) based on the ID param and a query based on the currently resolved
permissions and puts it in `assigns`.
* For events that do not carry an `"id"` param (e.g. updating a record with form data), **reloads the
record** currently assigned to `@loaded_resource`, using either Permit.Ecto (and the record's ID) or
the existing loader function. This is done by default to ensure permissions are evaluated against the
latest data. You can disable this behaviour by overriding `reload_on_event?/2` (or by passing the
`:reload_on_event?` option) if you prefer to reuse the already assigned record.

Event to action mapping is given using the `@permit_action` module attribute put right before an event
handler.

Event to action mapping must be given in the `event_mapping/0` callback. There is no default mapping as
event names typically suggested by Phoenix may map to different actions (e.g. Phoenix generates `"save"`
for both `:create` and `:update` actions).
@impl true
@permit_action :update
def handle_event("save", %{"article" => article_params}, socket) do
article = socket.assigns.loaded_resource

case MyApp.update_article(article_params) do
# ...
end
end

In this example, the `"save"` event handler is authorized against the `:update` action on `MyApp.Article`.

Note that there is no default mapping as event names typically suggested by Phoenix may map to different
actions (e.g. Phoenix generates `"save"` for both `:create` and `:update` actions).

When the `handle_event/3` function is not implemented using pattern matching on the first argument,
the `event_mapping` callback must be used instead.

@impl true
# "delete" event maps to :delete Permit action
def event_mapping, do: %{"delete" => :delete}
def event_mapping, do: %{"delete" => :delete, "remove" => :delete}

@impl true
def handle_event("delete", _params, _socket) do
def handle_event(event_name, _params, _socket) when event_name in ["delete", "remove"] do
# Resource is loaded and authorized by Permit
article = socket.assigns.loaded_resource

Expand All @@ -162,6 +185,8 @@ defmodule Permit.Phoenix.LiveView do
{:noreply, stream_delete(socket, :loaded_resources, article)}
end

If authorization fails, `handle_unauthorized/2` is called. Handling authorization failure is as simple as:

@impl true
def handle_unauthorized(:delete, socket) do
# You actually don't need to implement it, but it's useful for defining custom behaviour.
Expand Down Expand Up @@ -236,6 +261,19 @@ defmodule Permit.Phoenix.LiveView do
@callback id_struct_field_name(Types.action_group(), PhoenixTypes.socket()) :: atom()
@callback unauthorized_message(PhoenixTypes.socket(), map()) :: binary()
@callback event_mapping() :: map()
@doc ~S"""
For events that do not carry an `"id"` param (e.g. updating a record with form data), determines whether to reload the record before each event authorization.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥


Defaults to `true`.

## Example

@impl true
def reload_on_event?(_action, _socket) do
true
end
"""
@callback reload_on_event?(Types.action_group(), PhoenixTypes.socket()) :: boolean()
@callback use_stream?(PhoenixTypes.socket()) :: boolean()
@doc ~S"""
Determines whether to use Phoenix Scopes for fetching the subject. Set to `false` in Phoenix <1.8.
Expand Down Expand Up @@ -282,15 +320,22 @@ defmodule Permit.Phoenix.LiveView do
handle_not_found: 1,
unauthorized_message: 2,
use_stream?: 1,
reload_on_event?: 2,
use_scope?: 0,
scope_subject: 1
]
|> Enum.filter(& &1)

defmacro __using__(opts) do
# credo:disable-for-next-line
quote generated: true do
import unquote(__MODULE__)

Module.register_attribute(__MODULE__, :permit_action, accumulate: true)
Module.register_attribute(__MODULE__, :__event_mapping__, [])
@__event_mapping__ %{}
@on_definition Permit.Phoenix.Decorators.LiveView

if unquote(@permit_ecto_available?) do
require Ecto.Query
end
Expand All @@ -299,8 +344,9 @@ defmodule Permit.Phoenix.LiveView do
@before_compile unquote(__MODULE__)
@opts unquote(opts)

@impl true
def event_mapping, do: unquote(__MODULE__).event_mapping()
# event mapping is defined in the __before_compile__ callback to ensure it is
# available to the module before the __before_compile__ callback is executed.
@before_compile unquote(__MODULE__)

@impl true
def handle_unauthorized(action, socket) do
Expand Down Expand Up @@ -328,7 +374,7 @@ defmodule Permit.Phoenix.LiveView do

@impl true
def preload_actions,
do: (unquote(opts[:preload_actions]) || []) ++ [:show, :edit, :index, :delete]
do: (unquote(opts[:preload_actions]) || []) ++ [:show, :edit, :index, :delete, :update]

@impl true
def fallback_path(action, socket) do
Expand Down Expand Up @@ -382,12 +428,23 @@ defmodule Permit.Phoenix.LiveView do
end
end

@impl true
def reload_on_event?(action, socket) do
case unquote(opts[:reload_on_event?]) do
fun when is_function(fun) -> fun.(action, socket)
value when value in [nil, true] -> true
false -> false
_ -> raise ":reload_on_event? must be a function or a boolean"
end
end

@impl true
def use_scope? do
case unquote(opts[:use_scope?]) do
fun when is_function(fun) -> fun.()
nil -> true
other -> other
value when value in [nil, true] -> true
false -> false
_ -> raise ":use_scope? must be a function or a boolean"
end
end

Expand Down Expand Up @@ -431,9 +488,9 @@ defmodule Permit.Phoenix.LiveView do
action_grouping: 0,
singular_actions: 0,
use_stream?: 1,
event_mapping: 0,
use_scope?: 0,
scope_subject: 1
scope_subject: 1,
reload_on_event?: 2
]
|> Enum.filter(& &1)
)
Expand All @@ -442,6 +499,22 @@ defmodule Permit.Phoenix.LiveView do

defmacro __before_compile__(_env) do
quote do
if Module.defines?(__MODULE__, {:event_mapping, 0}) do
# it appears the developer has defined their own event_mapping/0 function,
# so we will disregard the default event mapping, and only merge the developer's
# implementation with whatever was defined using @permit_action module attributes.
defoverridable event_mapping: 0
def event_mapping, do: super() |> Map.merge(@__event_mapping__)
else
# no event_mapping/0 function defined, so we use the default event mapping + whatever
# was defined in the module using @permit_action.
@impl true
def event_mapping,
do: unquote(__MODULE__).default_event_mapping() |> Map.merge(@__event_mapping__)

defoverridable event_mapping: 0
end

if Module.defines?(__MODULE__, {:loader, 1}) do
def use_loader?, do: true
else
Expand Down Expand Up @@ -482,8 +555,22 @@ defmodule Permit.Phoenix.LiveView do
RuntimeError -> false
end

@doc false
def event_mapping do
# Default event mapping will not map "save" to any action. It is not unambiguous
# whether "save" should be mapped to :create or :update. Since Phoenix generators use "save"
# for both create and update actions, it will be up to the developer to clarify the mapping using:
#
# ```elixir
# @permit_action :create
# def handle_event("save", params, socket) do
# {:noreply, socket}
# end
#
# @permit_action :update
# def handle_event("save", params, socket) do
# {:noreply, socket}
# end
# ```
def default_event_mapping do
%{
"create" => :create,
"delete" => :delete,
Expand Down
Loading
Loading