diff --git a/assets/js/app.js b/assets/js/app.js index 4906c49..b9765da 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -11,12 +11,16 @@ import "../node_modules/bootstrap-icons/icons/person-circle.svg"; // accounts me import "../node_modules/bootstrap-icons/icons/person-plus.svg"; // new user / register import "../node_modules/bootstrap-icons/icons/box-arrow-in-left.svg"; // log in import "../node_modules/bootstrap-icons/icons/box-arrow-right.svg"; // log out +import "../node_modules/bootstrap-icons/icons/sliders.svg"; // new user / register // forms etc import "../node_modules/bootstrap-icons/icons/at.svg"; import "../node_modules/bootstrap-icons/icons/key.svg"; import "../node_modules/bootstrap-icons/icons/key-fill.svg"; +import "../node_modules/bootstrap-icons/icons/lock.svg"; import "../node_modules/bootstrap-icons/icons/shield-lock.svg"; import "../node_modules/bootstrap-icons/icons/arrow-repeat.svg"; +import "../node_modules/bootstrap-icons/icons/door-open.svg"; // log in +import "../node_modules/@mdi/svg/svg/head-question-outline.svg"; // brand // webpack automatically bundles all modules in your // entry points. Those entry points can be configured diff --git a/lib/bones73k_web/controllers/user_auth.ex b/lib/bones73k_web/controllers/user_auth.ex index cd0de8e..c7c280e 100644 --- a/lib/bones73k_web/controllers/user_auth.ex +++ b/lib/bones73k_web/controllers/user_auth.ex @@ -1,6 +1,7 @@ defmodule Bones73kWeb.UserAuth do import Plug.Conn import Phoenix.Controller + import Phoenix.HTML alias Bones73k.Accounts alias Bones73kWeb.Router.Helpers, as: Routes @@ -28,14 +29,17 @@ defmodule Bones73kWeb.UserAuth do """ def log_in_user(conn, user, params \\ %{}) do token = Accounts.generate_user_session_token(user) - user_return_to = get_session(conn, :user_return_to) conn |> renew_session() |> put_session(:user_token, token) |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") |> maybe_write_remember_me_cookie(token, params) - |> redirect(to: user_return_to || signed_in_path(conn)) + |> put_flash( + :info, + raw("Welcome back, #{user.email} — you were logged in successfuly.") + ) + |> redirect(to: get_session(conn, :user_return_to) || signed_in_path(conn)) end defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do diff --git a/lib/bones73k_web/controllers/user_reset_password_controller.ex b/lib/bones73k_web/controllers/user_reset_password_controller.ex index 3ff6dcd..d7d289b 100644 --- a/lib/bones73k_web/controllers/user_reset_password_controller.ex +++ b/lib/bones73k_web/controllers/user_reset_password_controller.ex @@ -1,9 +1,10 @@ defmodule Bones73kWeb.UserResetPasswordController do use Bones73kWeb, :controller + import Phoenix.LiveView.Controller alias Bones73k.Accounts - plug(:get_user_by_reset_password_token when action in [:edit, :update]) + plug(:get_user_by_reset_password_token when action in [:edit]) def new(conn, _params) do render(conn, "new.html") @@ -21,34 +22,20 @@ defmodule Bones73kWeb.UserResetPasswordController do conn |> put_flash( :info, - "If your email is in our system, you will receive instructions to reset your password shortly." + "If your email is in our system, you'll receive instructions to reset your password shortly." ) |> redirect(to: "/") end def edit(conn, _params) do - render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user)) - end - - # Do not log in the user after reset password to avoid a - # leaked token giving the user access to the account. - 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: Routes.user_session_path(conn, :new)) - - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end + live_render(conn, Bones73kWeb.UserLive.ResetPassword) 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) + put_session(conn, "user_id", user.id) else conn |> put_flash(:error, "Reset password link is invalid or it has expired.") diff --git a/lib/bones73k_web/controllers/user_session_controller.ex b/lib/bones73k_web/controllers/user_session_controller.ex index fe5f61f..ecc8436 100644 --- a/lib/bones73k_web/controllers/user_session_controller.ex +++ b/lib/bones73k_web/controllers/user_session_controller.ex @@ -6,7 +6,6 @@ defmodule Bones73kWeb.UserSessionController do alias Bones73kWeb.UserAuth def new(conn, _params) do - # IO.inspect(conn.private, label: "session_new conn.private :") render(conn, "new.html", error_message: nil) end diff --git a/lib/bones73k_web/live/user/registration.html.leex b/lib/bones73k_web/live/user/registration.html.leex index c2c7368..a03ccbd 100644 --- a/lib/bones73k_web/live/user/registration.html.leex +++ b/lib/bones73k_web/live/user/registration.html.leex @@ -20,7 +20,6 @@ class: input_class(f, :email, "form-control"), placeholder: "e.g., babka@73k.us", maxlength: User.max_email, - required: true, autofocus: true, phx_debounce: "blur", aria_describedby: error_id(f, :email) @@ -38,7 +37,6 @@ <%= password_input f, :password, value: input_value(f, :password), class: input_class(f, :password, "form-control"), - minlength: User.min_password, maxlength: User.max_password, required: true, phx_debounce: "200", diff --git a/lib/bones73k_web/live/user/reset_password.ex b/lib/bones73k_web/live/user/reset_password.ex new file mode 100644 index 0000000..c3c9f04 --- /dev/null +++ b/lib/bones73k_web/live/user/reset_password.ex @@ -0,0 +1,40 @@ +defmodule Bones73kWeb.UserLive.ResetPassword do + use Bones73kWeb, :live_view + + alias Bones73k.Accounts + alias Bones73k.Accounts.User + + @impl true + def mount(_params, session, socket) do + user = Accounts.get_user!(session["user_id"]) + + socket + |> assign_defaults(session) + |> assign(page_title: "Reset password") + |> assign(changeset: Accounts.change_user_password(user)) + |> assign(user: user) + |> live_okreply() + end + + @impl true + def handle_event("validate", %{"user" => user_params}, socket) do + cs = Accounts.change_user_password(socket.assigns.user, user_params) + {:noreply, socket |> assign(changeset: %{cs | action: :update})} + end + + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.reset_user_password(socket.assigns.user, user_params) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:success, "Password reset successfully.") + |> redirect(to: Routes.user_session_path(socket, :new))} + + {:error, changeset} -> + {:noreply, + socket + |> put_flash(:error, "Please check the errors below.") + |> assign(changeset: %{changeset | action: :update})} + end + end +end diff --git a/lib/bones73k_web/live/user/reset_password.html.leex b/lib/bones73k_web/live/user/reset_password.html.leex new file mode 100644 index 0000000..ad0bc23 --- /dev/null +++ b/lib/bones73k_web/live/user/reset_password.html.leex @@ -0,0 +1,57 @@ +
+
+ +

+ <%= icon_div @socket, "bi-shield-lock", [class: "icon baseline"] %> + Reset password +

+

Hi <%= @user.email %> — What new word of passage will confirm you are who you say you are?

+ + <%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, novalidate: true, id: "pw_reset_form"], fn f -> %> + +
+ <%= label f, :password, "New password", class: "form-label" %> +
+ + <%= icon_div @socket, "bi-key", [class: "icon fs-5"] %> + + <%= password_input f, :password, + value: input_value(f, :password), + class: input_class(f, :password, "form-control"), + maxlength: User.max_password, + autofocus: true, + aria_describedby: error_id(f, :password) + %> + <%= error_tag f, :password %> +
+
+ +
+ <%= label f, :password_confirmation, "Confirm new password", class: "form-label" %> +
+ + <%= icon_div @socket, "bi-key-fill", [class: "icon fs-5"] %> + + <%= password_input f, :password_confirmation, + value: input_value(f, :password_confirmation), + class: input_class(f, :password_confirmation, "form-control"), + maxlength: User.max_password, + aria_describedby: error_id(f, :password_confirmation) + %> + <%= error_tag f, :password_confirmation %> +
+
+ +
+ <%= submit "Reset password", disabled: !@changeset.valid?, class: "btn btn-primary", phx_disable_with: "Saving..." %> +
+ + <% end %> + +

+ <%= link "Register", to: Routes.user_registration_path(@socket, :new) %> | + <%= link "Log in", to: Routes.user_session_path(@socket, :new) %> +

+ +
+
diff --git a/lib/bones73k_web/router.ex b/lib/bones73k_web/router.ex index d3b55db..b7d3cfc 100644 --- a/lib/bones73k_web/router.ex +++ b/lib/bones73k_web/router.ex @@ -60,19 +60,12 @@ defmodule Bones73kWeb.Router do scope "/", Bones73kWeb do pipe_through([:browser, :redirect_if_user_is_authenticated]) - # # liveview user auth routes - # live "/users/reset_password", UserLive.ResetPassword, :new - # live "/users/reset_password/:token", UserLive.ResetPassword, :edit - - # original user auth routes from phx.gen.auth get("/users/register", UserRegistrationController, :new) get("/users/log_in", UserSessionController, :new) post("/users/log_in", UserSessionController, :create) - # TODO: 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 scope "/", Bones73kWeb do diff --git a/lib/bones73k_web/templates/layout/navbar/_user_menu.html.eex b/lib/bones73k_web/templates/layout/navbar/_user_menu.html.eex index 6fa6b94..f15a368 100644 --- a/lib/bones73k_web/templates/layout/navbar/_user_menu.html.eex +++ b/lib/bones73k_web/templates/layout/navbar/_user_menu.html.eex @@ -13,6 +13,7 @@
  • <%= link nav_link_opts(@conn, to: Routes.user_settings_path(@conn, :edit), class: "dropdown-item") do %> + <%= icon_div @conn, "bi-sliders", [class: "icon baseline me-1"] %> Settings <% end %>
  • diff --git a/lib/bones73k_web/templates/user_confirmation/new.html.eex b/lib/bones73k_web/templates/user_confirmation/new.html.eex index 3ce9608..1d6e724 100644 --- a/lib/bones73k_web/templates/user_confirmation/new.html.eex +++ b/lib/bones73k_web/templates/user_confirmation/new.html.eex @@ -5,7 +5,7 @@ <%= icon_div @conn, "bi-arrow-repeat", [class: "icon baseline"] %> Resend confirmation instructions -

    We'll send you another email with a link to confirm your email address.

    +

    We'll send you another email with instructions to confirm your email address.

    <%= form_for :user, Routes.user_confirmation_path(@conn, :create), [class: "needs-validation", novalidate: true], fn f -> %> diff --git a/lib/bones73k_web/templates/user_reset_password/edit.html.eex b/lib/bones73k_web/templates/user_reset_password/edit.html.eex deleted file mode 100644 index 94a550a..0000000 --- a/lib/bones73k_web/templates/user_reset_password/edit.html.eex +++ /dev/null @@ -1,26 +0,0 @@ -

    Reset password

    - -<%= form_for @changeset, Routes.user_reset_password_path(@conn, :update, @token), fn f -> %> - <%= if @changeset.action do %> -
    -

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

    -
    - <% end %> - - <%= label f, :password, "New password" %> - <%= password_input f, :password, required: true %> - <%= error_tag f, :password %> - - <%= label f, :password_confirmation, "Confirm new password" %> - <%= password_input f, :password_confirmation, required: true %> - <%= error_tag f, :password_confirmation %> - -
    - <%= submit "Reset password" %> -
    -<% end %> - -

    - <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | - <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> -

    diff --git a/lib/bones73k_web/templates/user_reset_password/new.html.eex b/lib/bones73k_web/templates/user_reset_password/new.html.eex index 619c535..891b97a 100644 --- a/lib/bones73k_web/templates/user_reset_password/new.html.eex +++ b/lib/bones73k_web/templates/user_reset_password/new.html.eex @@ -1,15 +1,38 @@ -

    Forgot your password?

    +
    +
    -<%= form_for :user, Routes.user_reset_password_path(@conn, :create), fn f -> %> - <%= label f, :email %> - <%= email_input f, :email, required: true %> +

    + <%= icon_div @conn, "mdi-head-question-outline", [class: "icon baseline"] %> + Forgot your password? +

    +

    We'll send you an email with instructions to reset your password.

    + + <%= form_for :user, Routes.user_reset_password_path(@conn, :create), [class: "needs-validation", novalidate: true], fn f -> %> + + <%= label f, :email, class: "form-label" %> +
    + + <%= icon_div @conn, "bi-at", [class: "icon fs-5"] %> + + <%= email_input f, :email, + placeholder: "e.g., babka@73k.us", + class: "form-control", + maxlength: User.max_email, + required: true, + autofocus: true + %> + must be a valid email address +
    + +
    + <%= submit "Send instructions to reset password", class: "btn btn-primary" %> +
    + <% end %> + +

    + <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> +

    -
    - <%= submit "Send instructions to reset password" %>
    -<% end %> - -

    - <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | - <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> -

    +
    diff --git a/lib/bones73k_web/templates/user_session/new.html.eex b/lib/bones73k_web/templates/user_session/new.html.eex index 995a0be..f251bf3 100644 --- a/lib/bones73k_web/templates/user_session/new.html.eex +++ b/lib/bones73k_web/templates/user_session/new.html.eex @@ -2,7 +2,7 @@

    - <%= icon_div @conn, "bi-box-arrow-in-left", [class: "icon baseline"] %> + <%= icon_div @conn, "bi-door-open", [class: "icon baseline"] %> Log in

    Who goes there?

    @@ -32,7 +32,7 @@ <%= label f, :password, class: "form-label" %>
    - <%= icon_div @conn, "bi-shield-lock", [class: "icon fs-5"] %> + <%= icon_div @conn, "bi-lock", [class: "icon fs-5"] %> <%= password_input f, :password, class: "form-control", diff --git a/lib/bones73k_web/views/user_reset_password_view.ex b/lib/bones73k_web/views/user_reset_password_view.ex index c8a1f90..a2c1d19 100644 --- a/lib/bones73k_web/views/user_reset_password_view.ex +++ b/lib/bones73k_web/views/user_reset_password_view.ex @@ -1,3 +1,4 @@ defmodule Bones73kWeb.UserResetPasswordView do use Bones73kWeb, :view + alias Bones73k.Accounts.User end diff --git a/test/bones73k_web/controllers/user_reset_password_controller_test.exs b/test/bones73k_web/controllers/user_reset_password_controller_test.exs index 1b429b8..9a7de0e 100644 --- a/test/bones73k_web/controllers/user_reset_password_controller_test.exs +++ b/test/bones73k_web/controllers/user_reset_password_controller_test.exs @@ -13,7 +13,7 @@ defmodule Bones73kWeb.UserResetPasswordControllerTest do test "renders the reset password page", %{conn: conn} do conn = get(conn, Routes.user_reset_password_path(conn, :new)) response = html_response(conn, 200) - assert response =~ "

    Forgot your password?

    " + assert response =~ "Forgot your password?\n " end end @@ -52,9 +52,10 @@ defmodule Bones73kWeb.UserResetPasswordControllerTest do %{token: token} end - test "renders reset password", %{conn: conn, token: token} do + test "renders reset password with user_id in session", %{conn: conn, token: token, user: user} do conn = get(conn, Routes.user_reset_password_path(conn, :edit, token)) - assert html_response(conn, 200) =~ "

    Reset password

    " + assert get_session(conn, "user_id") == user.id + assert html_response(conn, 200) =~ "Reset password\n " end test "does not render reset password with invalid token", %{conn: conn} do @@ -73,41 +74,5 @@ defmodule Bones73kWeb.UserResetPasswordControllerTest do %{token: token} end - - test "resets password once", %{conn: conn, user: user, token: token} do - conn = - put(conn, Routes.user_reset_password_path(conn, :update, token), %{ - "user" => %{ - "password" => "new valid password", - "password_confirmation" => "new valid password" - } - }) - - assert redirected_to(conn) == Routes.user_session_path(conn, :new) - refute get_session(conn, :user_token) - assert get_flash(conn, :info) =~ "Password reset successfully" - assert Accounts.get_user_by_email_and_password(user.email, "new valid password") - end - - test "does not reset password on invalid data", %{conn: conn, token: token} do - conn = - put(conn, Routes.user_reset_password_path(conn, :update, token), %{ - "user" => %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - }) - - response = html_response(conn, 200) - assert response =~ "

    Reset password

    " - assert response =~ "should be at least 12 character(s)" - assert response =~ "does not match password" - end - - test "does not reset password with invalid token", %{conn: conn} do - conn = put(conn, Routes.user_reset_password_path(conn, :update, "oops")) - assert redirected_to(conn) == "/" - assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" - end end end diff --git a/test/bones73k_web/live/user/registration_test.ex b/test/bones73k_web/live/user/registration_test.exs similarity index 94% rename from test/bones73k_web/live/user/registration_test.ex rename to test/bones73k_web/live/user/registration_test.exs index 06f3a6a..76b72a2 100644 --- a/test/bones73k_web/live/user/registration_test.ex +++ b/test/bones73k_web/live/user/registration_test.exs @@ -1,8 +1,6 @@ defmodule Bones73kWeb.UserLive.RegistrationTest do use Bones73kWeb.ConnCase - # import Plug.Conn - # import Phoenix.ConnTest import Phoenix.LiveViewTest import Bones73k.AccountsFixtures @@ -34,10 +32,11 @@ defmodule Bones73kWeb.UserLive.RegistrationTest do assert html =~ "Register\n " assert html =~ "must be a valid email address" assert html =~ "should be at least #{User.min_password()} character(s)" + assert html =~ "type=\"submit\" disabled=\"disabled\"" end @tag :capture_log - test "creates account and sets login params_token and phx-trigger-action", %{ + test "creates account, sets login token & phx-trigger-action", %{ conn: conn, user_return_to: user_return_to } do diff --git a/test/bones73k_web/live/user/reset_password_test.exs b/test/bones73k_web/live/user/reset_password_test.exs new file mode 100644 index 0000000..afab58d --- /dev/null +++ b/test/bones73k_web/live/user/reset_password_test.exs @@ -0,0 +1,57 @@ +defmodule Bones73kWeb.UserLive.ResetPasswordTest do + use Bones73kWeb.ConnCase + + import Phoenix.LiveViewTest + import Bones73k.AccountsFixtures + + alias Bones73k.Repo + alias Bones73k.Accounts + alias Bones73k.Accounts.User + alias Bones73k.Accounts.UserToken + + setup %{conn: conn} do + user = user_fixture() + conn = init_test_session(conn, %{"user_id" => user.id}) + %{conn: conn, user: user} + end + + test "displays registration form", %{conn: conn, user: user} do + {:ok, _view, html} = live_isolated(conn, Bones73kWeb.UserLive.ResetPassword) + + assert html =~ "Reset password\n " + assert html =~ user.email + assert html =~ "New password" + end + + test "render errors for invalid data", %{conn: conn} do + {:ok, view, _html} = live_isolated(conn, Bones73kWeb.UserLive.ResetPassword) + + form_data = %{"user" => %{"password" => "abc", "password_confirmation" => "def"}} + html = form(view, "#pw_reset_form", form_data) |> render_change() + + assert html =~ "Reset password\n " + assert html =~ "should be at least #{User.min_password()} character(s)" + assert html =~ "does not match password" + assert html =~ "type=\"submit\" disabled=\"disabled\"" + end + + @tag :capture_log + test "saves new password once", %{conn: conn, user: user} do + {:ok, view, _html} = live_isolated(conn, Bones73kWeb.UserLive.ResetPassword) + + # Render submitting a new password + new_pw = "valid_new_pass_123" + form_data = %{"user" => %{"password" => new_pw, "password_confirmation" => new_pw}} + _html = form(view, "#pw_reset_form", form_data) |> render_submit() + + # Confirm redirected + flash = assert_redirected(view, Routes.user_session_path(conn, :new)) + assert flash["success"] == "Password reset successfully." + + # Confirm password was updated + assert Accounts.get_user_by_email_and_password(user.email, new_pw) + + # Tokens have been deleted + assert [] == Repo.all(UserToken.user_and_contexts_query(user, :all)) + end +end