defmodule AshAuthentication.AddOn.Confirmation.ConfirmationHookChange do
@moduledoc """
Triggers a confirmation flow when one of the monitored fields is changed.
Optionally inhibits changes to monitored fields on update.
You can use this change in your actions where you want to send the user a
confirmation (or inhibit changes after confirmation). If you're not using one
of the actions generated by the confirmation add-on then you'll need to
manually pass the strategy name in the changeset context. Eg:
```elixir
Changeset.new(user, %{})
|> Changeset.set_context(%{strategy_name: :confirm})
|> Changeset.for_update(:update, params)
|> Accounts.update()
```
or by adding it statically to your action definition:
```elixir
update :change_email do
change set_context(%{strategy_name: :confirm})
change AshAuthentication.AddOn.Confirmation.ConfirmationHookChange
end
```
or by adding it as an option to the change definition:
```elixir
update :change_email do
change {AshAuthentication.AddOn.Confirmation.ConfirmationHookChange, strategy_name: :confirm}
end
```
"""
use Ash.Resource.Change
alias Ash.{Changeset, Resource.Change}
alias AshAuthentication.{AddOn.Confirmation, Info}
@doc false
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, options, context) do
case Info.find_strategy(changeset, context, options) do
{:ok, strategy} ->
do_change(changeset, strategy)
:error ->
changeset
end
end
defp do_change(changeset, strategy) do
changeset
|> Changeset.before_action(fn changeset ->
changeset
|> not_confirm_action(strategy)
|> should_confirm_action_type(strategy)
|> monitored_field_changing(strategy)
|> changes_would_be_valid()
|> maybe_inhibit_updates(strategy)
|> maybe_perform_confirmation(strategy, changeset)
end)
end
defp not_confirm_action(%Changeset{} = changeset, strategy)
when changeset.action != strategy.confirm_action_name,
do: changeset
defp not_confirm_action(_changeset, _strategy), do: nil
defp should_confirm_action_type(%Changeset{} = changeset, strategy)
when changeset.action_type == :create and strategy.confirm_on_create?,
do: changeset
defp should_confirm_action_type(%Changeset{} = changeset, strategy)
when changeset.action_type == :update and strategy.confirm_on_update?,
do: changeset
defp should_confirm_action_type(_changeset, _strategy), do: nil
defp monitored_field_changing(%Changeset{} = changeset, strategy) do
if Enum.any?(strategy.monitor_fields, &Changeset.changing_attribute?(changeset, &1)),
do: changeset,
else: nil
end
defp monitored_field_changing(_changeset, _strategy), do: nil
defp changes_would_be_valid(%Changeset{} = changeset) when changeset.valid?, do: changeset
defp changes_would_be_valid(_), do: nil
defp maybe_inhibit_updates(%Changeset{} = changeset, strategy)
when changeset.action_type == :update and strategy.inhibit_updates? do
strategy.monitor_fields
|> Enum.reduce(changeset, &Changeset.clear_change(&2, &1))
end
defp maybe_inhibit_updates(changeset, _strategy), do: changeset
defp maybe_perform_confirmation(%Changeset{} = changeset, strategy, original_changeset) do
changeset
|> nil_confirmed_at_field(strategy)
|> Changeset.after_action(fn _changeset, user ->
strategy
|> Confirmation.confirmation_token(original_changeset, user)
|> case do
{:ok, token} ->
{sender, send_opts} = strategy.sender
sender.send(user, token, Keyword.put(send_opts, :changeset, original_changeset))
metadata =
user.__metadata__
|> Map.put(:confirmation_token, token)
{:ok, %{user | __metadata__: metadata}}
_ ->
{:ok, user}
end
end)
end
defp maybe_perform_confirmation(_changeset, _strategy, original_changeset),
do: original_changeset
defp nil_confirmed_at_field(changeset, strategy) do
# If we're updating values, and we are inhibiting values on the changes (enforced at the call site)
# then we want to reset the confirmed_at_field to `nil`
# However, we do it `lazily` in case something they've done is already changing
# the `confirmed_at`. This might happen in a social sign up where the email has
# already been verified
if changeset.action.type == :update do
Changeset.force_change_new_attribute(changeset, strategy.confirmed_at_field, nil)
else
changeset
end
end
end