diff --git a/apps/fg_http/lib/fg_http/password_resets.ex b/apps/fg_http/lib/fg_http/password_resets.ex new file mode 100644 index 000000000..6093b786f --- /dev/null +++ b/apps/fg_http/lib/fg_http/password_resets.ex @@ -0,0 +1,104 @@ +defmodule FgHttp.PasswordResets do + @moduledoc """ + The PasswordResets context. + """ + + import Ecto.Query, warn: false + alias FgHttp.Repo + + alias FgHttp.PasswordResets.PasswordReset + + @doc """ + Returns the list of password_resets. + + ## Examples + + iex> list_password_resets() + [%PasswordReset{}, ...] + + """ + def list_password_resets do + Repo.all(PasswordReset) + end + + @doc """ + Gets a single password_reset. + + Raises `Ecto.NoResultsError` if the Password reset does not exist. + + ## Examples + + iex> get_password_reset!(123) + %PasswordReset{} + + iex> get_password_reset!(456) + ** (Ecto.NoResultsError) + + """ + def get_password_reset!(id), do: Repo.get!(PasswordReset, id) + + @doc """ + Creates a password_reset. + + ## Examples + + iex> create_password_reset(%{field: value}) + {:ok, %PasswordReset{}} + + iex> create_password_reset(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_password_reset(attrs \\ %{}) do + %PasswordReset{} + |> PasswordReset.create_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a password_reset. + + ## Examples + + iex> update_password_reset(password_reset, %{field: new_value}) + {:ok, %PasswordReset{}} + + iex> update_password_reset(password_reset, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_password_reset(%PasswordReset{} = password_reset, attrs) do + password_reset + |> PasswordReset.update_changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a password_reset. + + ## Examples + + iex> delete_password_reset(password_reset) + {:ok, %PasswordReset{}} + + iex> delete_password_reset(password_reset) + {:error, %Ecto.Changeset{}} + + """ + def delete_password_reset(%PasswordReset{} = password_reset) do + Repo.delete(password_reset) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking password_reset changes. + + ## Examples + + iex> change_password_reset(password_reset) + %Ecto.Changeset{data: %PasswordReset{}} + + """ + def change_password_reset(%PasswordReset{} = password_reset, attrs \\ %{}) do + PasswordReset.changeset(password_reset, attrs) + end +end diff --git a/apps/fg_http/lib/fg_http/password_resets/password_reset.ex b/apps/fg_http/lib/fg_http/password_resets/password_reset.ex new file mode 100644 index 000000000..fe9ad877c --- /dev/null +++ b/apps/fg_http/lib/fg_http/password_resets/password_reset.ex @@ -0,0 +1,64 @@ +defmodule FgHttp.PasswordResets.PasswordReset do + @moduledoc """ + Schema for PasswordReset + """ + + use Ecto.Schema + import Ecto.Changeset + + alias FgHttp.{Users, Users.User} + + @token_num_bytes 8 + + schema "password_resets" do + field :reset_sent_at, :utc_datetime + field :reset_token, :string + field :user_email, :string, virtual: true + belongs_to :user, User + + timestamps() + end + + @doc false + def changeset(password_reset, attrs) do + password_reset + |> cast(attrs, [:user_id, :user_email, :reset_sent_at, :reset_token]) + end + + @doc false + def create_changeset(password_reset, attrs) do + password_reset + |> cast(attrs, [:user_id, :user_email, :reset_sent_at, :reset_token]) + |> load_user_from_email() + |> generate_reset_token() + |> validate_required([:reset_token, :user_id]) + |> unique_constraint(:reset_token) + end + + @doc false + def update_changeset(password_reset, attrs) do + password_reset + |> cast(attrs, [:user_id, :user_email, :reset_sent_at, :reset_token]) + |> validate_required([:reset_token]) + end + + defp load_user_from_email( + %Ecto.Changeset{ + valid?: true, + changes: %{user_email: user_email} + } = changeset + ) do + user = Users.get_user!(email: user_email) + put_change(changeset, :user_id, user.id) + end + + defp load_user_from_email(changeset), do: changeset + + defp generate_reset_token(%Ecto.Changeset{valid?: true} = changeset) do + random_bytes = :crypto.strong_rand_bytes(@token_num_bytes) + random_string = Base.url_encode64(random_bytes) + put_change(changeset, :reset_token, random_string) + end + + defp generate_reset_token(changeset), do: changeset +end diff --git a/apps/fg_http/lib/fg_http/users.ex b/apps/fg_http/lib/fg_http/users.ex index 323dd66b3..08f0a2b85 100644 --- a/apps/fg_http/lib/fg_http/users.ex +++ b/apps/fg_http/lib/fg_http/users.ex @@ -39,10 +39,6 @@ defmodule FgHttp.Users do Repo.get_by!(User, email: email) end - def get_user!(reset_token: reset_token) do - Repo.get_by!(User, reset_token: reset_token) - end - def get_user!(id), do: Repo.get!(User, id) @doc """ diff --git a/apps/fg_http/lib/fg_http/users/user.ex b/apps/fg_http/lib/fg_http/users/user.ex index b01f6f987..9e682f9e3 100644 --- a/apps/fg_http/lib/fg_http/users/user.ex +++ b/apps/fg_http/lib/fg_http/users/user.ex @@ -11,8 +11,6 @@ defmodule FgHttp.Users.User do schema "users" do field :email, :string field :confirmed_at, :utc_datetime - field :reset_sent_at, :utc_datetime - field :reset_token, :string field :last_signed_in_at, :utc_datetime field :password_hash, :string diff --git a/apps/fg_http/lib/fg_http_web/controllers/password_reset_controller.ex b/apps/fg_http/lib/fg_http_web/controllers/password_reset_controller.ex index f5531b7db..d686cdf61 100644 --- a/apps/fg_http/lib/fg_http_web/controllers/password_reset_controller.ex +++ b/apps/fg_http/lib/fg_http_web/controllers/password_reset_controller.ex @@ -4,35 +4,28 @@ defmodule FgHttpWeb.PasswordResetController do """ use FgHttpWeb, :controller - alias FgHttp.{Users, Users.User} + alias FgHttp.{PasswordResets, PasswordResets.PasswordReset} plug FgHttpWeb.Plugs.RedirectAuthenticated def new(conn, _params) do + changeset = PasswordReset.changeset(%PasswordReset{}, %{}) + conn - |> render("new.html", changeset: User.changeset(%User{})) + |> render("new.html", changeset: changeset) end - # Don't actually create anything. Instead, update the user with a reset token and send - # the password reset email. - def create(conn, %{ - "password_reset" => - %{ - reset_token: reset_token, - password: _password, - password_confirmation: _password_confirmation, - current_password: _current_password - } = user_params - }) do - user = Users.get_user!(reset_token: reset_token) - - case Users.update_user(user, user_params) do - {:ok, _user} -> + def create(conn, %{"password_reset" => %{"user_email" => _} = password_reset_params}) do + case PasswordResets.create_password_reset(password_reset_params) do + {:ok, _password_reset} -> conn - |> render("success.html") + |> clear_session() + |> put_flash(:info, "Password reset successfully. Please sign in with your new password.") + |> redirect(to: Routes.session_path(conn, :new)) {:error, changeset} -> conn + |> put_flash(:error, "Error creating password reset.") |> render("new.html", changeset: changeset) end end diff --git a/apps/fg_http/lib/fg_http_web/router.ex b/apps/fg_http/lib/fg_http_web/router.ex index a2c7a33d0..7594b7851 100644 --- a/apps/fg_http/lib/fg_http_web/router.ex +++ b/apps/fg_http/lib/fg_http_web/router.ex @@ -20,6 +20,8 @@ defmodule FgHttpWeb.Router do scope "/", FgHttpWeb do pipe_through :browser + resources "/password_resets", PasswordResetController, only: [:new, :create] + resources "/user", UserController, singleton: true, only: [:show, :edit, :update, :delete] resources "/users", UserController, only: [:new, :create] diff --git a/apps/fg_http/lib/fg_http_web/templates/layout/app.html.eex b/apps/fg_http/lib/fg_http_web/templates/layout/app.html.eex index a4f9211f2..570eb2794 100644 --- a/apps/fg_http/lib/fg_http_web/templates/layout/app.html.eex +++ b/apps/fg_http/lib/fg_http_web/templates/layout/app.html.eex @@ -14,16 +14,17 @@ - <%= if @user_signed_in? do %> -
+ <%= render_flash(@conn) %> <%= @inner_content %>
diff --git a/apps/fg_http/lib/fg_http_web/templates/password_reset/edit.html.eex b/apps/fg_http/lib/fg_http_web/templates/password_reset/edit.html.eex new file mode 100644 index 000000000..ed57b0cd4 --- /dev/null +++ b/apps/fg_http/lib/fg_http_web/templates/password_reset/edit.html.eex @@ -0,0 +1,5 @@ +

Edit Password reset

+ +<%= render "form.html", Map.put(assigns, :action, Routes.password_reset_path(@conn, :update, @password_reset)) %> + +<%= link "Back", to: Routes.password_reset_path(@conn, :index) %> diff --git a/apps/fg_http/lib/fg_http_web/templates/password_reset/form.html.eex b/apps/fg_http/lib/fg_http_web/templates/password_reset/form.html.eex new file mode 100644 index 000000000..23bcc3e43 --- /dev/null +++ b/apps/fg_http/lib/fg_http_web/templates/password_reset/form.html.eex @@ -0,0 +1,19 @@ +<%= form_for @changeset, @action, fn f -> %> + <%= if @changeset.action do %> +
+

Oops, something went wrong! Please check the errors below.

+
+ <% end %> + + <%= label f, :reset_sent_at %> + <%= datetime_select f, :reset_sent_at %> + <%= error_tag f, :reset_sent_at %> + + <%= label f, :reset_token %> + <%= text_input f, :reset_token %> + <%= error_tag f, :reset_token %> + +
+ <%= submit "Save" %> +
+<% end %> diff --git a/apps/fg_http/lib/fg_http_web/templates/password_reset/index.html.eex b/apps/fg_http/lib/fg_http_web/templates/password_reset/index.html.eex new file mode 100644 index 000000000..1139f045f --- /dev/null +++ b/apps/fg_http/lib/fg_http_web/templates/password_reset/index.html.eex @@ -0,0 +1,28 @@ +

Listing Password resets

+ + + + + + + + + + + +<%= for password_reset <- @password_resets do %> + + + + + + +<% end %> + +
Reset sent atReset token
<%= password_reset.reset_sent_at %><%= password_reset.reset_token %> + <%= link "Show", to: Routes.password_reset_path(@conn, :show, password_reset) %> + <%= link "Edit", to: Routes.password_reset_path(@conn, :edit, password_reset) %> + <%= link "Delete", to: Routes.password_reset_path(@conn, :delete, password_reset), method: :delete, data: [confirm: "Are you sure?"] %> +
+ +<%= link "New Password reset", to: Routes.password_reset_path(@conn, :new) %> diff --git a/apps/fg_http/lib/fg_http_web/templates/password_reset/new.html.eex b/apps/fg_http/lib/fg_http_web/templates/password_reset/new.html.eex new file mode 100644 index 000000000..5181f4870 --- /dev/null +++ b/apps/fg_http/lib/fg_http_web/templates/password_reset/new.html.eex @@ -0,0 +1,5 @@ +

New Password reset

+ +<%= render "form.html", Map.put(assigns, :action, Routes.password_reset_path(@conn, :create)) %> + +<%= link "Back", to: Routes.session_path(@conn, :new) %> diff --git a/apps/fg_http/lib/fg_http_web/templates/password_reset/show.html.eex b/apps/fg_http/lib/fg_http_web/templates/password_reset/show.html.eex new file mode 100644 index 000000000..a3470a023 --- /dev/null +++ b/apps/fg_http/lib/fg_http_web/templates/password_reset/show.html.eex @@ -0,0 +1,18 @@ +

Show Password reset

+ + + +<%= link "Edit", to: Routes.password_reset_path(@conn, :edit, @password_reset) %> +<%= link "Back", to: Routes.password_reset_path(@conn, :index) %> diff --git a/apps/fg_http/lib/fg_http_web/views/layout_view.ex b/apps/fg_http/lib/fg_http_web/views/layout_view.ex index 083ee3439..27136b0fc 100644 --- a/apps/fg_http/lib/fg_http_web/views/layout_view.ex +++ b/apps/fg_http/lib/fg_http_web/views/layout_view.ex @@ -1,3 +1,20 @@ defmodule FgHttpWeb.LayoutView do use FgHttpWeb, :view + + def render_flash(conn) do + ~E""" +
+ <%= if get_flash(conn, :error) do %> +
+ <%= get_flash(conn, :error) %> +
+ <% end %> + <%= if get_flash(conn, :error) do %> +
+ <%= get_flash(conn, :error) %> +
+ <% end %> +
+ """ + end end diff --git a/apps/fg_http/lib/fg_http_web/views/password_reset_view.ex b/apps/fg_http/lib/fg_http_web/views/password_reset_view.ex new file mode 100644 index 000000000..16ddc7cd3 --- /dev/null +++ b/apps/fg_http/lib/fg_http_web/views/password_reset_view.ex @@ -0,0 +1,3 @@ +defmodule FgHttpWeb.PasswordResetView do + use FgHttpWeb, :view +end diff --git a/apps/fg_http/priv/repo/migrations/20200225005454_create_users.exs b/apps/fg_http/priv/repo/migrations/20200225005454_create_users.exs index 926607f58..f2254342d 100644 --- a/apps/fg_http/priv/repo/migrations/20200225005454_create_users.exs +++ b/apps/fg_http/priv/repo/migrations/20200225005454_create_users.exs @@ -7,8 +7,6 @@ defmodule FgHttp.Repo.Migrations.CreateUsers do add :confirmed_at, :utc_datetime add :password_hash, :string add :last_signed_in_at, :utc_datetime - add :reset_sent_at, :utc_datetime - add :reset_token, :utc_datetime timestamps() end diff --git a/apps/fg_http/priv/repo/migrations/20200521154921_create_password_resets.exs b/apps/fg_http/priv/repo/migrations/20200521154921_create_password_resets.exs new file mode 100644 index 000000000..54b3ff387 --- /dev/null +++ b/apps/fg_http/priv/repo/migrations/20200521154921_create_password_resets.exs @@ -0,0 +1,16 @@ +defmodule FgHttp.Repo.Migrations.CreatePasswordResets do + use Ecto.Migration + + def change do + create table(:password_resets) do + add :reset_sent_at, :utc_datetime + add :reset_token, :string, null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create unique_index(:password_resets, [:reset_token]) + create index(:password_resets, [:user_id]) + end +end diff --git a/apps/fg_http/test/fg_http/password_resets_test.exs b/apps/fg_http/test/fg_http/password_resets_test.exs new file mode 100644 index 000000000..9b2d984c8 --- /dev/null +++ b/apps/fg_http/test/fg_http/password_resets_test.exs @@ -0,0 +1,87 @@ +defmodule FgHttp.PasswordResetsTest do + use FgHttp.DataCase + + alias FgHttp.{Fixtures, PasswordResets} + + describe "password_resets" do + alias FgHttp.PasswordResets.PasswordReset + + @valid_attrs %{reset_sent_at: "2010-04-17T14:00:00Z"} + @update_attrs %{reset_sent_at: "2011-05-18T15:01:01Z"} + @invalid_attrs %{reset_sent_at: nil} + + def password_reset_fixture(attrs \\ %{}) do + {:ok, password_reset} = + attrs + |> Enum.into(%{user_id: Fixtures.user().id}) + |> Enum.into(@valid_attrs) + |> PasswordResets.create_password_reset() + + password_reset + end + + test "list_password_resets/0 returns all password_resets" do + password_reset = password_reset_fixture() + assert PasswordResets.list_password_resets() == [password_reset] + end + + test "get_password_reset!/1 returns the password_reset with given id" do + password_reset = password_reset_fixture() + assert PasswordResets.get_password_reset!(password_reset.id) == password_reset + end + + test "create_password_reset/1 with valid data creates a password_reset" do + user_id = Fixtures.user().id + valid_attrs = Map.merge(@valid_attrs, %{user_id: user_id}) + + assert {:ok, %PasswordReset{} = password_reset} = + PasswordResets.create_password_reset(valid_attrs) + + assert password_reset.reset_sent_at == + DateTime.from_naive!(~N[2010-04-17T14:00:00Z], "Etc/UTC") + + assert password_reset.reset_token + assert password_reset.user_id == user_id + end + + test "create_password_reset/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = PasswordResets.create_password_reset(@invalid_attrs) + end + + test "update_password_reset/2 with valid data updates the password_reset" do + password_reset = password_reset_fixture() + + assert {:ok, %PasswordReset{} = password_reset} = + PasswordResets.update_password_reset(password_reset, @update_attrs) + + assert password_reset.reset_sent_at == + DateTime.from_naive!(~N[2011-05-18T15:01:01Z], "Etc/UTC") + + assert password_reset.reset_token + end + + test "update_password_reset/2 with invalid data returns error changeset" do + invalid_attrs = Map.merge(@invalid_attrs, %{reset_token: nil}) + password_reset = password_reset_fixture() + + assert {:error, %Ecto.Changeset{}} = + PasswordResets.update_password_reset(password_reset, invalid_attrs) + + assert password_reset == PasswordResets.get_password_reset!(password_reset.id) + end + + test "delete_password_reset/1 deletes the password_reset" do + password_reset = password_reset_fixture() + assert {:ok, %PasswordReset{}} = PasswordResets.delete_password_reset(password_reset) + + assert_raise Ecto.NoResultsError, fn -> + PasswordResets.get_password_reset!(password_reset.id) + end + end + + test "change_password_reset/1 returns a password_reset changeset" do + password_reset = password_reset_fixture() + assert %Ecto.Changeset{} = PasswordResets.change_password_reset(password_reset) + end + end +end diff --git a/apps/fg_http/test/fg_http_web/controllers/device_controller_test.exs b/apps/fg_http/test/fg_http_web/controllers/device_controller_test.exs index 173cd25a2..73462e4d0 100644 --- a/apps/fg_http/test/fg_http_web/controllers/device_controller_test.exs +++ b/apps/fg_http/test/fg_http_web/controllers/device_controller_test.exs @@ -1,7 +1,7 @@ defmodule FgHttpWeb.DeviceControllerTest do use FgHttpWeb.ConnCase, async: true - import FgHttp.Fixtures + alias FgHttp.Fixtures @create_attrs %{public_key: "foobar"} @update_attrs %{name: "some updated name"} @@ -73,7 +73,7 @@ defmodule FgHttpWeb.DeviceControllerTest do end defp create_device(_) do - device = fixture(:device) + device = Fixtures.device() {:ok, device: device} end end diff --git a/apps/fg_http/test/fg_http_web/controllers/password_reset_controller_test.exs b/apps/fg_http/test/fg_http_web/controllers/password_reset_controller_test.exs new file mode 100644 index 000000000..8d6d9ab88 --- /dev/null +++ b/apps/fg_http/test/fg_http_web/controllers/password_reset_controller_test.exs @@ -0,0 +1,20 @@ +defmodule FgHttpWeb.PasswordResetControllerTest do + use FgHttpWeb.ConnCase, async: true + + @create_attrs %{user_email: "test"} + + describe "new password_reset" do + test "renders form", %{unauthed_conn: conn} do + conn = get(conn, Routes.password_reset_path(conn, :new)) + assert html_response(conn, 200) =~ "New Password reset" + end + end + + describe "create password_reset" do + test "redirects to sign in when data is valid", %{unauthed_conn: conn} do + conn = post(conn, Routes.password_reset_path(conn, :create), password_reset: @create_attrs) + + assert redirected_to(conn) == Routes.session_path(conn, :new) + end + end +end diff --git a/apps/fg_http/test/support/conn_case.ex b/apps/fg_http/test/support/conn_case.ex index d2a6af700..613187bf6 100644 --- a/apps/fg_http/test/support/conn_case.ex +++ b/apps/fg_http/test/support/conn_case.ex @@ -19,7 +19,7 @@ defmodule FgHttpWeb.ConnCase do alias Ecto.Adapters.SQL.Sandbox - import FgHttp.Fixtures + alias FgHttp.Fixtures using do quote do @@ -38,10 +38,10 @@ defmodule FgHttpWeb.ConnCase do end def authed_conn do - user = fixture(:user) + user = Fixtures.user() session = - fixture(:session, %{ + Fixtures.session(%{ user_id: user.id, user_password: "test", user_email: "test" diff --git a/apps/fg_http/test/support/fixtures.ex b/apps/fg_http/test/support/fixtures.ex index 31f5122fc..5f759de25 100644 --- a/apps/fg_http/test/support/fixtures.ex +++ b/apps/fg_http/test/support/fixtures.ex @@ -2,12 +2,15 @@ defmodule FgHttp.Fixtures do @moduledoc """ Convenience helpers for inserting records """ - alias FgHttp.{Devices, Repo, Sessions, Users, Users.User} + alias FgHttp.{Devices, PasswordResets, Repo, Sessions, Users, Users.User} - def fixture(:user) do + def user(attrs \\ %{}) do case Repo.get_by(User, email: "test") do nil -> - attrs = %{email: "test", password: "test", password_confirmation: "test"} + attrs = + attrs + |> Enum.into(%{email: "test", password: "test", password_confirmation: "test"}) + {:ok, user} = Users.create_user(attrs) user @@ -16,13 +19,24 @@ defmodule FgHttp.Fixtures do end end - def fixture(:device) do - attrs = %{public_key: "foobar", ifname: "wg0", name: "factory"} - {:ok, device} = Devices.create_device(Map.merge(%{user_id: fixture(:user).id}, attrs)) + def device(attrs \\ %{}) do + attrs = + attrs + |> Enum.into(%{user_id: user().id}) + |> Enum.into(%{public_key: "foobar", ifname: "wg0", name: "factory"}) + + {:ok, device} = Devices.create_device(attrs) device end - def fixture(:session, attrs \\ %{}) do - {:ok, _session} = Sessions.create_session(attrs) + def session(attrs \\ %{}) do + {:ok, session} = Sessions.create_session(attrs) + session + end + + def password_reset(attrs \\ %{}) do + create_attrs = Map.merge(attrs, %{user_email: user().email}) + {:ok, password_reset} = PasswordResets.create_password_reset(create_attrs) + password_reset end end