Skip to content

Commit b457f6c

Browse files
fixup 17 user forgot password
1 parent d3902b7 commit b457f6c

File tree

6 files changed

+330
-0
lines changed

6 files changed

+330
-0
lines changed

lib/radiator/accounts.ex

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,52 @@ defmodule Radiator.Accounts do
327327
end)
328328
end
329329

330+
## Reset password
331+
332+
@doc """
333+
Gets the user by reset password token.
334+
335+
## Examples
336+
337+
iex> get_user_by_reset_password_token("validtoken")
338+
%User{}
339+
340+
iex> get_user_by_reset_password_token("invalidtoken")
341+
nil
342+
343+
"""
344+
def get_user_by_reset_password_token(token) do
345+
with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
346+
%User{} = user <- Repo.one(query) do
347+
user
348+
else
349+
_ -> nil
350+
end
351+
end
352+
353+
@doc """
354+
Resets the user password.
355+
356+
## Examples
357+
358+
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
359+
{:ok, %User{}}
360+
361+
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
362+
{:error, %Ecto.Changeset{}}
363+
364+
"""
365+
def reset_user_password(user, attrs) do
366+
Ecto.Multi.new()
367+
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
368+
|> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
369+
|> Repo.transaction()
370+
|> case do
371+
{:ok, %{user: user}} -> {:ok, user}
372+
{:error, :user, changeset, _} -> {:error, changeset}
373+
end
374+
end
375+
330376
## API
331377

332378
@doc """

lib/radiator/accounts/user_notifier.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,28 @@ defmodule Radiator.Accounts.UserNotifier do
4848
end
4949
end
5050

51+
@doc ~S"""
52+
Delivers the reset password email to the given user.
53+
54+
## Examples
55+
56+
iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}"))
57+
{:ok, %{to: ..., body: ...}}
58+
59+
"""
60+
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
61+
when is_function(reset_password_url_fun, 1) do
62+
{encoded_token, user_token} =
63+
Radiator.Accounts.UserToken.build_email_token(user, "reset_password")
64+
65+
Radiator.Repo.insert!(user_token)
66+
67+
Radiator.Accounts.UserNotifier.deliver_user_reset_password_instructions(
68+
user,
69+
reset_password_url_fun.(encoded_token)
70+
)
71+
end
72+
5173
defp deliver_magic_link_instructions(user, url) do
5274
deliver(user.email, "Log in instructions", """
5375

lib/radiator/accounts/user_token.ex

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,41 @@ defmodule Radiator.Accounts.UserToken do
123123
end
124124
end
125125

126+
@doc """
127+
Checks if the token is valid and returns its underlying lookup query.
128+
129+
The query returns the user found by the token, if any.
130+
131+
The given token is valid if it matches its hashed counterpart in the
132+
database and the user email has not changed. This function also checks
133+
if the token is being used within a certain period, depending on the
134+
context. The default contexts supported by this function are either
135+
"confirm", for account confirmation emails, and "reset_password",
136+
for resetting the password. For verifying requests to change the email,
137+
see `verify_change_email_token_query/2`.
138+
"""
139+
def verify_email_token_query(token, context) do
140+
case Base.url_decode64(token, padding: false) do
141+
{:ok, decoded_token} ->
142+
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
143+
days = days_for_context(context)
144+
145+
query =
146+
from token in by_token_and_context_query(hashed_token, context),
147+
join: user in assoc(token, :user),
148+
where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
149+
select: user
150+
151+
{:ok, query}
152+
153+
:error ->
154+
:error
155+
end
156+
end
157+
158+
defp days_for_context("confirm"), do: @confirm_validity_in_days
159+
defp days_for_context("reset_password"), do: @reset_password_validity_in_days
160+
126161
def build_api_token(user) do
127162
token = :crypto.strong_rand_bytes(@rand_size)
128163
{token, %UserToken{token: token, context: "api", user_id: user.id}}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
defmodule RadiatorWeb.UserForgotPasswordLive do
2+
use RadiatorWeb, :live_view
3+
4+
alias Radiator.Accounts
5+
6+
def render(assigns) do
7+
~H"""
8+
<div class="mx-auto max-w-sm">
9+
<.header class="text-center">
10+
Forgot your password?
11+
<:subtitle>We'll send a password reset link to your inbox</:subtitle>
12+
</.header>
13+
14+
<.simple_form for={@form} id="reset_password_form" phx-submit="send_email">
15+
<.input field={@form[:email]} type="email" placeholder="Email" required />
16+
<:actions>
17+
<.button phx-disable-with="Sending..." class="w-full">
18+
Send password reset instructions
19+
</.button>
20+
</:actions>
21+
</.simple_form>
22+
<p class="text-center text-sm mt-4">
23+
<.link href={~p"/users/register"}>Register</.link>
24+
| <.link href={~p"/users/log-in"}>Log in</.link>
25+
</p>
26+
</div>
27+
"""
28+
end
29+
30+
def mount(_params, _session, socket) do
31+
{:ok, assign(socket, form: to_form(%{}, as: "user"))}
32+
end
33+
34+
def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
35+
if user = Accounts.get_user_by_email(email) do
36+
Accounts.UserNotifier.deliver_user_reset_password_instructions(
37+
user,
38+
&url(~p"/users/reset_password/#{&1}")
39+
)
40+
end
41+
42+
info =
43+
"If your email is in our system, you will receive instructions to reset your password shortly."
44+
45+
{:noreply,
46+
socket
47+
|> put_flash(:info, info)
48+
|> redirect(to: ~p"/")}
49+
end
50+
51+
@doc """
52+
Renders a simple form.
53+
54+
## Examples
55+
56+
<.simple_form for={@form} phx-change="validate" phx-submit="save">
57+
<.input field={@form[:email]} label="Email"/>
58+
<.input field={@form[:username]} label="Username" />
59+
<:actions>
60+
<.button>Save</.button>
61+
</:actions>
62+
</.simple_form>
63+
"""
64+
attr :for, :any, required: true, doc: "the data structure for the form"
65+
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
66+
67+
attr :rest, :global,
68+
include: ~w(autocomplete name rel action enctype method novalidate target multipart),
69+
doc: "the arbitrary HTML attributes to apply to the form tag"
70+
71+
slot :inner_block, required: true
72+
slot :actions, doc: "the slot for form actions, such as a submit button"
73+
74+
def simple_form(assigns) do
75+
~H"""
76+
<.form :let={f} for={@for} as={@as} {@rest}>
77+
<div class="mt-10 space-y-8 bg-white">
78+
{render_slot(@inner_block, f)}
79+
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
80+
{render_slot(action, f)}
81+
</div>
82+
</div>
83+
</.form>
84+
"""
85+
end
86+
end
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
defmodule RadiatorWeb.UserResetPasswordLive do
2+
use RadiatorWeb, :live_view
3+
4+
alias Radiator.Accounts
5+
6+
def render(assigns) do
7+
~H"""
8+
<div class="mx-auto max-w-sm">
9+
<.header class="text-center">Reset Password</.header>
10+
11+
<.simple_form
12+
for={@form}
13+
id="reset_password_form"
14+
phx-submit="reset_password"
15+
phx-change="validate"
16+
>
17+
<.error :if={@form.errors != []}>
18+
Oops, something went wrong! Please check the errors below.
19+
</.error>
20+
21+
<.input field={@form[:password]} type="password" label="New password" required />
22+
<.input
23+
field={@form[:password_confirmation]}
24+
type="password"
25+
label="Confirm new password"
26+
required
27+
/>
28+
<:actions>
29+
<.button phx-disable-with="Resetting..." class="w-full">Reset Password</.button>
30+
</:actions>
31+
</.simple_form>
32+
33+
<p class="text-center text-sm mt-4">
34+
<.link href={~p"/users/register"}>Register</.link>
35+
| <.link href={~p"/users/log-in"}>Log in</.link>
36+
</p>
37+
</div>
38+
"""
39+
end
40+
41+
def mount(params, _session, socket) do
42+
socket = assign_user_and_token(socket, params)
43+
44+
form_source =
45+
case socket.assigns do
46+
%{user: user} ->
47+
Accounts.change_user_password(user)
48+
49+
_ ->
50+
%{}
51+
end
52+
53+
{:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]}
54+
end
55+
56+
# Do not log in the user after reset password to avoid a
57+
# leaked token giving the user access to the account.
58+
def handle_event("reset_password", %{"user" => user_params}, socket) do
59+
case Accounts.reset_user_password(socket.assigns.user, user_params) do
60+
{:ok, _} ->
61+
{:noreply,
62+
socket
63+
|> put_flash(:info, "Password reset successfully.")
64+
|> redirect(to: ~p"/users/log-in")}
65+
66+
{:error, changeset} ->
67+
{:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
68+
end
69+
end
70+
71+
def handle_event("validate", %{"user" => user_params}, socket) do
72+
changeset = Accounts.change_user_password(socket.assigns.user, user_params)
73+
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
74+
end
75+
76+
defp assign_user_and_token(socket, %{"token" => token}) do
77+
if user = Accounts.get_user_by_reset_password_token(token) do
78+
assign(socket, user: user, token: token)
79+
else
80+
socket
81+
|> put_flash(:error, "Reset password link is invalid or it has expired.")
82+
|> redirect(to: ~p"/")
83+
end
84+
end
85+
86+
defp assign_form(socket, %{} = source) do
87+
assign(socket, :form, to_form(source, as: "user"))
88+
end
89+
90+
@doc """
91+
Renders a simple form.
92+
93+
## Examples
94+
95+
<.simple_form for={@form} phx-change="validate" phx-submit="save">
96+
<.input field={@form[:email]} label="Email"/>
97+
<.input field={@form[:username]} label="Username" />
98+
<:actions>
99+
<.button>Save</.button>
100+
</:actions>
101+
</.simple_form>
102+
"""
103+
attr :for, :any, required: true, doc: "the data structure for the form"
104+
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
105+
106+
attr :rest, :global,
107+
include: ~w(autocomplete name rel action enctype method novalidate target multipart),
108+
doc: "the arbitrary HTML attributes to apply to the form tag"
109+
110+
slot :inner_block, required: true
111+
slot :actions, doc: "the slot for form actions, such as a submit button"
112+
113+
def simple_form(assigns) do
114+
~H"""
115+
<.form :let={f} for={@for} as={@as} {@rest}>
116+
<div class="mt-10 space-y-8 bg-white">
117+
{render_slot(@inner_block, f)}
118+
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
119+
{render_slot(action, f)}
120+
</div>
121+
</div>
122+
</.form>
123+
"""
124+
end
125+
126+
@doc """
127+
Generates a generic error message.
128+
"""
129+
slot :inner_block, required: true
130+
131+
def error(assigns) do
132+
~H"""
133+
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600">
134+
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
135+
{render_slot(@inner_block)}
136+
</p>
137+
"""
138+
end
139+
end

lib/radiator_web/router.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ defmodule RadiatorWeb.Router do
8181
live "/users/register", UserLive.Registration, :new
8282
live "/users/log-in", UserLive.Login, :new
8383
live "/users/log-in/:token", UserLive.Confirmation, :new
84+
live "/users/reset_password", UserForgotPasswordLive, :new
85+
live "/users/reset_password/:token", UserResetPasswordLive, :edit
8486
end
8587

8688
post "/users/log-in", UserSessionController, :create

0 commit comments

Comments
 (0)