Pow - utilise existing code to prevent change of email address?

I’m moving to Pow and have a profile section in my website that breaks up user data entry into several sections/pages. These pages are tied back to a UserController. On one of the pages, the user can change their email address.

Is there any way I can piggyback on to the event hooks tied to the Pow.Phoenix.RegistrationController but with my UserController? Then my app could follow the default email/registration workflow provided by Pow?

The Pow.Phoenix.RegistrationController is extremely thin. Most, or maybe all, of what you need exists in the ecto module(s), so I would recommend you to just leverage those (maybe Pow.Ecto.Schema.Changeset.user_id_field_changeset/3 is what you’re looking for).

I can provide some sample code, but would need to know some more of what you need. Do you require a password to update the email? Do you use PowEmailConfirmation?

1 Like

Sorry, I should have mentioned that I am using PowEmailConfirmation

It was more the warn_unconfirmed stuff in controller_callbacks that I was after. But based on what you’re saying, I’d be better off writing the workflow myself? I was just hoping that I could be lazy and avoid doing that.

For reference, here’s a sample of my User schema:

defmodule Zoinks.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  alias Zoinks.Accounts.User

  use Pow.Ecto.Schema,
    user_id_field: :user_name,
    password_hash_methods: {
      &Comeonin.Bcrypt.hashpwsalt/1,
      &Comeonin.Bcrypt.checkpw/2
    }

  use Pow.Extension.Ecto.Schema,
    extensions: [PowResetPassword, PowEmailConfirmation]

  @timestamps_opts [type: :utc_datetime_usec]
  schema "users" do
    field :user_name, :string
    field :email, :string
    field :name, :string
    field :bio, :string

    pow_user_fields()

    timestamps()
  end

  @doc false
  def changeset( user_or_changeset, attrs ) do
    user_or_changeset
    |> pow_changeset(attrs)
    |> pow_extension_changeset(attrs)
    |> cast(attrs, [:email, :unconfirmed_email])
  end
end

It’s a work in progress, but its worth mentioning that email is not the user_id_field - so I’ve added a cast to email and unconfirmed_email in changeset

Also, I thought there was a circumstance where controller_callbacks would prevent an update (if you have an unconfirmed email, that it would prevent you from changing it to different unconfirmed email).

But re-reading the code, I was mistaken. The title of this post is misleading as a result. Sorry about that!

Yeah, it’s most likely better to set it up in a custom controller than try to reuse the controller flow in Pow. You can leverage the Pow controller flow, but you will have much less control and understanding what’s going on, e.g. you will be redirected back to registration edit page rather than the edit page for e-mail.

This is the most basic setup I can think of, that you can expand upon:

defmodule ZoinksWeb.UserController do
  alias Zoinks.Accounts

  # ...

  def edit_email(conn, _params) do
    changeset = Pow.Plug.change_user(conn)

    render(conn, "edit_email.html", changeset: changeset)
  end

  def update_email(conn, %{"user" => user_params}) do
    conn
    |> Pow.Plug.update_user(user_params)
    |> maybe_warn_user()
  end

  defp warn_unconfirmed({:ok, %{email_confirmed_at: nil, email_confirmation_token: token}, conn}) when not is_nil(token) do
    conn
    |> put_flash(:info, "You'll need to confirm the e-mail before it's updated. An e-mail confirmation link has been sent to you.")
    |> redirect(to: Routes.user_path(conn, :edit_email))
  end
  defp warn_unconfirmed({:ok, _user, conn}) do
    redirect(conn, to: Routes.user_path(conn, :edit_email))
  end
  defp warn_unconfirmed({:error, changeset, conn}) do
    render(conn, "edit_email.html", changeset: changeset)
  end
end

The Pow.Plug.update_user/2 method can later be replaced so the user can only update the email rather than basically be the exact same as the Pow user update action.

2 Likes

Thanks!

You’re code was really helpful. The callbacks in PowEmailConfirmation.Ecto.Schema didn’t seem to be working for me. I eventually figured out my mistake. Here’s what I had originally:

  def changeset( user_or_changeset, attrs ) do
    user_or_changeset
    |> pow_changeset(attrs)
    |> pow_extension_changeset(attrs)
    |> cast(attrs, [:email, :unconfirmed_email])
  end

However, I didn’t realise that I needed to cast above pow_changeset. Once I did this:

  def changeset( user_or_changeset, attrs ) do
    user_or_changeset
    |> cast(attrs, [:email, :unconfirmed_email])
    |> pow_changeset(attrs)
    |> pow_extension_changeset(attrs)
  end

The hooks in PowEmailConfirmation.Ecto.Schema started working as expected. Now I can test/understand the default behaviour of Pow (with regards to allowing the user to change the email address).

It’s not necessary to cast anything, or at least you shouldn’t cast :unconfirmed_email as it should only be used internally (when the email is casted, the PowEmailConfirmation ecto changeset method will do the work for you). Also I forgot to add the email deliver logic so your modules should look like this:

  def changeset( user_or_changeset, attrs ) do
    user_or_changeset
    |> pow_changeset(attrs)
    |> pow_extension_changeset(attrs)
  end
defmodule ZoinksWeb.UserController do
  alias Zoinks.Accounts

  # ...

  def edit_email(conn, _params) do
    changeset = Pow.Plug.change_user(conn)

    render(conn, "edit_email.html", changeset: changeset)
  end

  def update_email(conn, %{"user" => user_params}) do
    conn
    |> Pow.Plug.update_user(user_params)
    |> maybe_warn_user()
  end

  defp warn_unconfirmed({:ok, %{email_confirmed_at: nil, email_confirmation_token: token} = user, conn}) when not is_nil(token) do
    PowEmailConfirmation.Phoenix.ControllerCallbacks.send_confirmation_email(user, conn)

    conn
    |> put_flash(:info, "You'll need to confirm the e-mail before it's updated. An e-mail confirmation link has been sent to you.")
    |> redirect(to: Routes.user_path(conn, :edit_email))
  end
  defp warn_unconfirmed({:ok, _user, conn}) do
    redirect(conn, to: Routes.user_path(conn, :edit_email))
  end
  defp warn_unconfirmed({:error, changeset, conn}) do
    render(conn, "edit_email.html", changeset: changeset)
  end
end
1 Like

After a little testing - looks like I need to cast :email (because I’ve set user_id_field?) but not :unconfirmed_email

You’re right, if :user_id_field is something different than email then you need to cast it yourself.

1 Like