pow
pow copied to clipboard
Document custom extension (reset password) controller
With all the issues I opened when I set up pow, the answer pretty much ended up being all the time "use custom controllers".
So I finally invested the time to switch to custom controllers and I must say it feels much cleaner this way and it went very smoothly ... at least for the documented controllers (registration and session).
As a continuation I needed to also customize the reset_password one ... and it's been much more painful...
Google comes with a single result and it is incomplete.
I am reporting here my final solution in order to :
- help others who might have the same need
- encourage an inclusion in the official documentation so that it becomes super simple like it was for
sessionandregistration. I would be in favor of putting it in the custom controllers page as even if it concerns extensions, the user would definitely identify what it needs or not. - ask a few questions
The code
Here's my router.ex:
scope "/", VaeWeb do
pipe_through [:browser, :not_authenticated]
resources("/reset-password", ResetPasswordController, as: :reset_password, only: [:new, :create, :edit, :update])
end
My reset_password_controller.ex:
defmodule MyAppWeb.ResetPasswordController do
use MyAppWeb, :controller
plug :load_user_from_reset_token when action in [:edit, :update]
def new(conn, _params) do
changeset = Pow.Plug.change_user(conn)
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"user" => user_params}) do
conn
|> PowResetPassword.Plug.create_reset_token(user_params)
|> case do
{:ok, %{token: token, user: user}, conn} ->
# send email
MyAppWeb.UserEmail.reset_password(user, token)
|> MyAppWeb.Mailer.send()
redirect_to_home(conn)
{:error, conn} -> redirect_to_home(conn)
end
end
def edit(conn, %{"id" => token}) do
changeset = Pow.Plug.change_user(conn)
render(conn, "edit.html", changeset: changeset, token: token)
end
def update(conn, %{"id" => token, "user" => user_params}) do
case PowResetPassword.Plug.update_user_password(conn, user_params) do
{:ok, _user, conn} ->
conn
|> put_flash(:info, "password updated")
|> redirect(to: Routes.login_path(conn, :new))
{:error, changeset, conn} ->
conn
|> render("edit.html", changeset: changeset, token: token)
end
end
defp redirect_to_home(conn) do
conn
|> put_flash(:info, "Email sent ...")
|> redirect(to: Routes.root_path(conn, :index))
end
defp load_user_from_reset_token(%{params: %{"id" => token}} = conn, _opts) do
case PowResetPassword.Plug.load_user_by_token(conn, token) do
{:error, conn} ->
conn
|> put_flash(:error, "Invalid token ...")
|> redirect(to: Routes.reset_password_path(conn, :new))
|> halt()
{:ok, conn} ->
conn
end
end
end
And templates (slim format). new.html.slim:
= form_for @changeset, Routes.reset_password_path(@conn, :create), [as: :user], fn f ->
= text_input f, :email
= submit "send email"
edit.html.slim:
= form_for @changeset, Routes.reset_password_path(@conn, :update, @token), [as: :user, method: :put], fn f ->
= password_input f, :password
= password_input f, :password_confirmation
= submit "Update password"
The questions
- In order to have my controller to work, I have had to add the plug:
plug :load_user_from_reset_token when action in [:edit, :update]
which is a private method in reset_password_controller.ex
Shouldn't it be public? Or moved somewhere else to be reusable? Like in a plug?
- In
def edit(conn, %{"id" => token})
def update(conn, %{"id" => token, "user" => user_params})
I need to get the token from the URL, and to pass it to the template to being able to generate the action: Routes.reset_password_path(@conn, :update, @token).
But it doesn't feel like the correct way to do this. Instead, I would expect to have the changeset initiated with conn.assigns[:reset_password_user], so that the token is read from there, and I don't have to enforce the method: :put in edit.html.slim as it comes from @changeset.
I haven't found a clean way to do that though :(
Thanks
Sorry for the late reply @augnustin!
Your solution looks great 💯
I agree that it would be nice to have extensions included in the custom controller guides. It also goes in hand with my plan for mix task generators, so you could ideally generate everything as custom controllers (akin to mix phx.gen.auth).
Shouldn't it be public? Or moved somewhere else to be reusable? Like in a plug?
It's very tightly coupled to the controller so I don't believe it makes sense to make it public. E.g. the route has to be changed for it to work in custom controller. What you have done is the recommended way as it's better if you got full control over the flow and can add the flash message and redirect path as desired.
But it doesn't feel like the correct way to do this. Instead, I would expect to have the changeset initiated with
conn.assigns[:reset_password_user], so that the token is read from there, and I don't have to enforce themethod: :putinedit.html.slimas it comes from@changeset.
Hmm, it shouldn't be necessary to set method: :put, it's handled by phoenix_ecto as the changeset is already loaded.
Also, the reason token is used in the URI rather than changeset is so you have these REST URI:
GET /reset-password/:id
PUT /reset-password/:id
Hey thanks for all the help @augnustin! I was stuck on this for awhile too and I'm pretty new to elixir so that was confusing haha. Some things to note for anyone else that sees this in the future:
-
Make sure you remove
PowResetPasswordas an extension in the following places:config.exsuser.exand yourrouter.ex -
Updated his create method to be more inline with what I had(using Bamboo Mailer)
def create(conn, %{"user" => user_params}) do
conn
|> PowResetPassword.Plug.create_reset_token(user_params)
|> case do
{:ok, %{token: token, user: user}, conn} ->
url = YhhWeb.Endpoint.url() <> "/reset-password/" <> token <> "/edit"
YhhWeb.Pow.Mailer.send_reset_password_email(user, url)
redirect_to_home(conn)
_ ->
redirect_to_home(conn)
end
end
The method for my mailer is pretty straight forward:
def send_reset_password_email(user, url) do
new_email(
to: user.email,
from: "[email protected]",
subject: "Reset password link",
html_body: "<body_here>",
text_body: "<text_body_here>"
) |> process
end
Also have a reset_password_view.ex:
defmodule YhhWeb.ResetPasswordView do
use YhhWeb, :view
end
Other than that just make sure your pathes are correct! Thanks again. This was super helpful!