feat(auth): self-service password reset
Add a "Forgot your password?" flow off the login page. A 24-hour reset_password token is emailed on request, the landing page lets the user pick a new password, and all other tokens for the user are revoked on success. The request endpoint returns the same flash regardless of whether the email matches a user so that attackers can't enumerate accounts.
This commit is contained in:
parent
d01e420b9d
commit
00aad3cb98
12 changed files with 464 additions and 0 deletions
|
|
@ -293,6 +293,47 @@ defmodule Microwaveprop.Accounts do
|
||||||
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
## Password reset
|
||||||
|
|
||||||
|
@doc ~S"""
|
||||||
|
Delivers self-service password-reset instructions to the given user.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset-password/#{&1}"))
|
||||||
|
{:ok, %{to: ..., body: ...}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
|
||||||
|
when is_function(reset_password_url_fun, 1) do
|
||||||
|
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
|
||||||
|
Repo.insert!(user_token)
|
||||||
|
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the user by a password-reset token, or nil if the token is invalid or expired.
|
||||||
|
"""
|
||||||
|
def get_user_by_reset_password_token(token) do
|
||||||
|
with {:ok, query} <- UserToken.verify_password_reset_token_query(token),
|
||||||
|
{%User{} = user, _token} <- Repo.one(query) do
|
||||||
|
user
|
||||||
|
else
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Resets the user password and revokes all tokens for that user.
|
||||||
|
|
||||||
|
Returns `{:ok, {user, expired_tokens}}` on success.
|
||||||
|
"""
|
||||||
|
def reset_user_password(%User{} = user, attrs) do
|
||||||
|
user
|
||||||
|
|> User.password_changeset(attrs)
|
||||||
|
|> update_user_and_delete_all_tokens()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Deletes the signed token with the given context.
|
Deletes the signed token with the given context.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,28 @@ defmodule Microwaveprop.Accounts.UserNotifier do
|
||||||
""")
|
""")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deliver instructions to reset a forgotten password.
|
||||||
|
"""
|
||||||
|
def deliver_reset_password_instructions(user, url) do
|
||||||
|
deliver(user.email, "Reset your NTMS Propagation password", """
|
||||||
|
|
||||||
|
==============================
|
||||||
|
|
||||||
|
Hi #{user.callsign},
|
||||||
|
|
||||||
|
Someone requested a password reset for your NTMS Propagation
|
||||||
|
account. To pick a new password, visit the URL below:
|
||||||
|
|
||||||
|
#{url}
|
||||||
|
|
||||||
|
This link expires in 24 hours. If you didn't request this,
|
||||||
|
please ignore this message — your password won't change.
|
||||||
|
|
||||||
|
==============================
|
||||||
|
""")
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Deliver instructions to update a user email.
|
Deliver instructions to update a user email.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ defmodule Microwaveprop.Accounts.UserToken do
|
||||||
@magic_link_validity_in_minutes 15
|
@magic_link_validity_in_minutes 15
|
||||||
@change_email_validity_in_days 7
|
@change_email_validity_in_days 7
|
||||||
@confirm_validity_in_days 1
|
@confirm_validity_in_days 1
|
||||||
|
@reset_password_validity_in_days 1
|
||||||
@session_validity_in_days 14
|
@session_validity_in_days 14
|
||||||
|
|
||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
|
@ -157,6 +158,31 @@ defmodule Microwaveprop.Accounts.UserToken do
|
||||||
@doc """
|
@doc """
|
||||||
Checks if the token is valid and returns its underlying lookup query.
|
Checks if the token is valid and returns its underlying lookup query.
|
||||||
|
|
||||||
|
Used to authorize a self-service password reset. The context is always
|
||||||
|
"reset_password" and the token is valid for @reset_password_validity_in_days.
|
||||||
|
"""
|
||||||
|
def verify_password_reset_token_query(token) do
|
||||||
|
case Base.url_decode64(token, padding: false) do
|
||||||
|
{:ok, decoded_token} ->
|
||||||
|
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||||
|
|
||||||
|
query =
|
||||||
|
from token in by_token_and_context_query(hashed_token, "reset_password"),
|
||||||
|
join: user in assoc(token, :user),
|
||||||
|
where: token.inserted_at > ago(^@reset_password_validity_in_days, "day"),
|
||||||
|
where: token.sent_to == user.email,
|
||||||
|
select: {user, token}
|
||||||
|
|
||||||
|
{:ok, query}
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
:error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks if the token is valid and returns its underlying lookup query.
|
||||||
|
|
||||||
The query returns the user_token found by the token, if any.
|
The query returns the user_token found by the token, if any.
|
||||||
|
|
||||||
This is used to validate requests to change the user
|
This is used to validate requests to change the user
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
defmodule MicrowavepropWeb.UserResetPasswordController do
|
||||||
|
use MicrowavepropWeb, :controller
|
||||||
|
|
||||||
|
alias Microwaveprop.Accounts
|
||||||
|
|
||||||
|
plug :get_user_by_reset_password_token when action in [:edit, :update]
|
||||||
|
|
||||||
|
def new(conn, _params) do
|
||||||
|
render(conn, :new)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create(conn, %{"user" => %{"email" => email}}) do
|
||||||
|
if user = Accounts.get_user_by_email(email) do
|
||||||
|
{:ok, _email} =
|
||||||
|
Accounts.deliver_user_reset_password_instructions(
|
||||||
|
user,
|
||||||
|
&url(~p"/users/reset-password/#{&1}")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Always respond the same way whether or not the email exists — this is
|
||||||
|
# a deliberate anti-enumeration measure. If we redirected differently on
|
||||||
|
# a miss, an attacker could iterate emails to discover registered accounts.
|
||||||
|
conn
|
||||||
|
|> put_flash(
|
||||||
|
:info,
|
||||||
|
"If your email is in our system, you will receive instructions to reset your password shortly."
|
||||||
|
)
|
||||||
|
|> redirect(to: ~p"/users/log-in")
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit(conn, _params) do
|
||||||
|
render(conn, :edit,
|
||||||
|
changeset: Accounts.change_user_password(conn.assigns.user),
|
||||||
|
token: conn.assigns.token
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update(conn, %{"user" => user_params}) do
|
||||||
|
case Accounts.reset_user_password(conn.assigns.user, user_params) do
|
||||||
|
{:ok, _} ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Password reset successfully.")
|
||||||
|
|> redirect(to: ~p"/users/log-in")
|
||||||
|
|
||||||
|
{:error, changeset} ->
|
||||||
|
render(conn, :edit, changeset: changeset, token: conn.assigns.token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_user_by_reset_password_token(conn, _opts) do
|
||||||
|
%{"token" => token} = conn.params
|
||||||
|
|
||||||
|
if user = Accounts.get_user_by_reset_password_token(token) do
|
||||||
|
conn |> assign(:user, user) |> assign(:token, token)
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Reset link is invalid or it has expired.")
|
||||||
|
|> redirect(to: ~p"/users/reset-password")
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
defmodule MicrowavepropWeb.UserResetPasswordHTML do
|
||||||
|
use MicrowavepropWeb, :html
|
||||||
|
|
||||||
|
embed_templates "user_reset_password_html/*"
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
|
<div class="mx-auto max-w-sm space-y-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<.header>
|
||||||
|
Reset your password
|
||||||
|
<:subtitle>Choose a new password for your account.</:subtitle>
|
||||||
|
</.header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.form
|
||||||
|
:let={f}
|
||||||
|
for={@changeset}
|
||||||
|
as={:user}
|
||||||
|
action={~p"/users/reset-password/#{@token}"}
|
||||||
|
method="put"
|
||||||
|
>
|
||||||
|
<.input
|
||||||
|
field={f[:password]}
|
||||||
|
type="password"
|
||||||
|
label="New password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
spellcheck="false"
|
||||||
|
required
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={f[:password_confirmation]}
|
||||||
|
type="password"
|
||||||
|
label="Confirm new password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
spellcheck="false"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.button phx-disable-with="Saving..." class="btn btn-primary w-full">
|
||||||
|
Save new password
|
||||||
|
</.button>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
|
<div class="mx-auto max-w-sm space-y-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<.header>
|
||||||
|
Reset your password
|
||||||
|
<:subtitle>
|
||||||
|
Enter the email on your account and we'll send you a link to pick a new password.
|
||||||
|
</:subtitle>
|
||||||
|
</.header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.form :let={f} for={%{}} as={:user} action={~p"/users/reset-password"}>
|
||||||
|
<.input
|
||||||
|
field={f[:email]}
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
autocomplete="username"
|
||||||
|
spellcheck="false"
|
||||||
|
required
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.button class="btn btn-primary w-full">
|
||||||
|
Send reset link
|
||||||
|
</.button>
|
||||||
|
</.form>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-base-content/70">
|
||||||
|
Remembered it?
|
||||||
|
<.link navigate={~p"/users/log-in"} class="font-semibold text-primary hover:underline">
|
||||||
|
Log in
|
||||||
|
</.link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
|
@ -43,5 +43,14 @@
|
||||||
Log in only this time
|
Log in only this time
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-base-content/70">
|
||||||
|
<.link
|
||||||
|
navigate={~p"/users/reset-password"}
|
||||||
|
class="font-semibold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</.link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,11 @@ defmodule MicrowavepropWeb.Router do
|
||||||
|
|
||||||
get "/users/register", UserRegistrationController, :new
|
get "/users/register", UserRegistrationController, :new
|
||||||
post "/users/register", UserRegistrationController, :create
|
post "/users/register", UserRegistrationController, :create
|
||||||
|
|
||||||
|
get "/users/reset-password", UserResetPasswordController, :new
|
||||||
|
post "/users/reset-password", UserResetPasswordController, :create
|
||||||
|
get "/users/reset-password/:token", UserResetPasswordController, :edit
|
||||||
|
put "/users/reset-password/:token", UserResetPasswordController, :update
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/", MicrowavepropWeb do
|
scope "/", MicrowavepropWeb do
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,85 @@ defmodule Microwaveprop.AccountsTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "deliver_user_reset_password_instructions/2" do
|
||||||
|
test "sends token through notification" do
|
||||||
|
user = user_fixture()
|
||||||
|
|
||||||
|
token =
|
||||||
|
extract_user_token(fn url ->
|
||||||
|
Accounts.deliver_user_reset_password_instructions(user, url)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, token} = Base.url_decode64(token, padding: false)
|
||||||
|
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
|
||||||
|
assert user_token.user_id == user.id
|
||||||
|
assert user_token.sent_to == user.email
|
||||||
|
assert user_token.context == "reset_password"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_user_by_reset_password_token/1" do
|
||||||
|
setup do
|
||||||
|
user = user_fixture()
|
||||||
|
|
||||||
|
token =
|
||||||
|
extract_user_token(fn url ->
|
||||||
|
Accounts.deliver_user_reset_password_instructions(user, url)
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{user: user, token: token}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns the user with valid token", %{user: %{id: id}, token: token} do
|
||||||
|
assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return the user with invalid token" do
|
||||||
|
refute Accounts.get_user_by_reset_password_token("oops")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return the user if token expired", %{token: token} do
|
||||||
|
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
|
||||||
|
refute Accounts.get_user_by_reset_password_token(token)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return the user with a confirm-context token" do
|
||||||
|
user = unconfirmed_user_fixture()
|
||||||
|
|
||||||
|
confirm_token =
|
||||||
|
extract_user_token(fn url ->
|
||||||
|
Accounts.deliver_user_confirmation_instructions(user, url)
|
||||||
|
end)
|
||||||
|
|
||||||
|
refute Accounts.get_user_by_reset_password_token(confirm_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "reset_user_password/2" do
|
||||||
|
setup do
|
||||||
|
%{user: user_fixture()}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates password length", %{user: user} do
|
||||||
|
{:error, changeset} = Accounts.reset_user_password(user, %{password: "short"})
|
||||||
|
assert %{password: ["should be at least 8 character(s)"]} = errors_on(changeset)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates the password", %{user: user} do
|
||||||
|
{:ok, {user, _tokens}} =
|
||||||
|
Accounts.reset_user_password(user, %{password: "new valid password"})
|
||||||
|
|
||||||
|
assert is_nil(user.password)
|
||||||
|
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "deletes all tokens for the given user", %{user: user} do
|
||||||
|
_ = Accounts.generate_user_session_token(user)
|
||||||
|
{:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"})
|
||||||
|
refute Repo.get_by(UserToken, user_id: user.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "change_user_registration/2" do
|
describe "change_user_registration/2" do
|
||||||
test "returns a changeset without hashing the password" do
|
test "returns a changeset without hashing the password" do
|
||||||
changeset = Accounts.change_user_registration(%User{})
|
changeset = Accounts.change_user_registration(%User{})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
defmodule MicrowavepropWeb.UserResetPasswordControllerTest do
|
||||||
|
use MicrowavepropWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Microwaveprop.AccountsFixtures
|
||||||
|
|
||||||
|
alias Microwaveprop.Accounts
|
||||||
|
alias Microwaveprop.Accounts.UserToken
|
||||||
|
alias Microwaveprop.Repo
|
||||||
|
|
||||||
|
setup do
|
||||||
|
%{user: user_fixture()}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /users/reset-password" do
|
||||||
|
test "renders the request form", %{conn: conn} do
|
||||||
|
conn = get(conn, ~p"/users/reset-password")
|
||||||
|
response = html_response(conn, 200)
|
||||||
|
assert response =~ "Reset your password"
|
||||||
|
assert response =~ "Email"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST /users/reset-password" do
|
||||||
|
@tag :capture_log
|
||||||
|
test "sends a new reset-password token for an existing email", %{conn: conn, user: user} do
|
||||||
|
conn = post(conn, ~p"/users/reset-password", %{"user" => %{"email" => user.email}})
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/users/log-in"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
|
||||||
|
~r/If your email is in our system/
|
||||||
|
|
||||||
|
assert Repo.get_by(UserToken, user_id: user.id, context: "reset_password")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not leak account existence for an unknown email", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
post(conn, ~p"/users/reset-password", %{
|
||||||
|
"user" => %{"email" => "ghost@example.com"}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/users/log-in"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
|
||||||
|
~r/If your email is in our system/
|
||||||
|
|
||||||
|
refute Repo.get_by(UserToken, context: "reset_password")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /users/reset-password/:token" do
|
||||||
|
setup %{user: user} do
|
||||||
|
token =
|
||||||
|
extract_user_token(fn url ->
|
||||||
|
Accounts.deliver_user_reset_password_instructions(user, url)
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{token: token}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders the new-password form for a valid token", %{conn: conn, token: token} do
|
||||||
|
conn = get(conn, ~p"/users/reset-password/#{token}")
|
||||||
|
response = html_response(conn, 200)
|
||||||
|
assert response =~ "Reset your password"
|
||||||
|
assert response =~ "New password"
|
||||||
|
assert response =~ "Confirm new password"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects with flash error when the token is invalid", %{conn: conn} do
|
||||||
|
conn = get(conn, ~p"/users/reset-password/oops")
|
||||||
|
assert redirected_to(conn) == ~p"/users/reset-password"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||||
|
"Reset link is invalid or it has expired"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PUT /users/reset-password/:token" do
|
||||||
|
setup %{user: user} do
|
||||||
|
token =
|
||||||
|
extract_user_token(fn url ->
|
||||||
|
Accounts.deliver_user_reset_password_instructions(user, url)
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{token: token}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "resets the password for a valid token", %{conn: conn, user: user, token: token} do
|
||||||
|
conn =
|
||||||
|
put(conn, ~p"/users/reset-password/#{token}", %{
|
||||||
|
"user" => %{
|
||||||
|
"password" => "new valid password",
|
||||||
|
"password_confirmation" => "new valid password"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/users/log-in"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
|
||||||
|
"Password reset successfully"
|
||||||
|
|
||||||
|
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
|
||||||
|
refute Repo.get_by(UserToken, user_id: user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders errors when the password is invalid", %{conn: conn, token: token} do
|
||||||
|
conn =
|
||||||
|
put(conn, ~p"/users/reset-password/#{token}", %{
|
||||||
|
"user" => %{"password" => "short", "password_confirmation" => "short"}
|
||||||
|
})
|
||||||
|
|
||||||
|
response = html_response(conn, 200)
|
||||||
|
assert response =~ "Reset your password"
|
||||||
|
assert response =~ "should be at least 8"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects with flash error when the token is invalid", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
put(conn, ~p"/users/reset-password/oops", %{
|
||||||
|
"user" => %{
|
||||||
|
"password" => "new valid password",
|
||||||
|
"password_confirmation" => "new valid password"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/users/reset-password"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||||
|
"Reset link is invalid or it has expired"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -18,6 +18,13 @@ defmodule MicrowavepropWeb.UserSessionControllerTest do
|
||||||
assert response =~ "Password"
|
assert response =~ "Password"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "renders a link to reset-password", %{conn: conn} do
|
||||||
|
conn = get(conn, ~p"/users/log-in")
|
||||||
|
response = html_response(conn, 200)
|
||||||
|
assert response =~ ~p"/users/reset-password"
|
||||||
|
assert response =~ "Forgot"
|
||||||
|
end
|
||||||
|
|
||||||
test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do
|
test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do
|
||||||
html =
|
html =
|
||||||
conn
|
conn
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue