diff --git a/lib/towerops/mobile_sessions.ex b/lib/towerops/mobile_sessions.ex index 952a3b90..d1afc616 100644 --- a/lib/towerops/mobile_sessions.ex +++ b/lib/towerops/mobile_sessions.ex @@ -23,6 +23,13 @@ defmodule Towerops.MobileSessions do |> Repo.insert() end + @doc """ + Gets a mobile session by ID. + """ + def get_session(session_id) do + Repo.get(MobileSession, session_id) + end + @doc """ Gets a mobile session by token. diff --git a/lib/towerops_web/controllers/user_settings_controller.ex b/lib/towerops_web/controllers/user_settings_controller.ex index 481edf46..7b9b486e 100644 --- a/lib/towerops_web/controllers/user_settings_controller.ex +++ b/lib/towerops_web/controllers/user_settings_controller.ex @@ -1,90 +1,13 @@ defmodule ToweropsWeb.UserSettingsController do + @moduledoc """ + Controller for user settings operations that require traditional request/response handling. + + Most user settings functionality is now handled by UserSettingsLive. + This controller only handles email confirmation via token. + """ use ToweropsWeb, :controller - import ToweropsWeb.UserAuth, only: [require_sudo_mode: 2] - alias Towerops.Accounts - alias ToweropsWeb.UserAuth - - plug :require_sudo_mode - plug :assign_email_and_password_changesets - - def edit(conn, _params) do - render(conn, :edit) - end - - def update(conn, %{"action" => "update_email"} = params) do - %{"user" => user_params} = params - user = conn.assigns.current_scope.user - - case Accounts.change_user_email(user, user_params) do - %{valid?: true} = changeset -> - _ = - Accounts.deliver_user_update_email_instructions( - Ecto.Changeset.apply_action!(changeset, :insert), - user.email, - &url(~p"/users/settings/confirm-email/#{&1}") - ) - - conn - |> put_flash( - :info, - "A link to confirm your email change has been sent to the new address." - ) - |> redirect(to: ~p"/users/settings") - - changeset -> - render(conn, :edit, email_changeset: %{changeset | action: :insert}) - end - end - - def update(conn, %{"action" => "update_password"} = params) do - %{"user" => user_params} = params - user = conn.assigns.current_scope.user - - case Accounts.update_user_password(user, user_params) do - {:ok, {user, _}} -> - conn - |> put_flash(:info, "Password updated successfully.") - |> put_session(:user_return_to, ~p"/users/settings") - |> UserAuth.log_in_user(user) - - {:error, changeset} -> - render(conn, :edit, password_changeset: changeset) - end - end - - def update(conn, %{"action" => "revoke_mobile_device", "session_id" => session_id}) do - case Towerops.MobileSessions.revoke_session(session_id) do - {:ok, _} -> - conn - |> put_flash(:info, "Mobile device removed successfully.") - |> redirect(to: ~p"/users/settings") - - {:error, _} -> - conn - |> put_flash(:error, "Failed to remove mobile device.") - |> redirect(to: ~p"/users/settings") - end - end - - def update(conn, %{"action" => "toggle_device_alerts", "session_id" => session_id, "enabled" => enabled}) do - enabled_bool = enabled == "true" - - case Towerops.MobileSessions.update_alert_preferences(session_id, %{alerts_enabled: enabled_bool}) do - {:ok, _} -> - message = if enabled_bool, do: "Alerts enabled for device", else: "Alerts disabled for device" - - conn - |> put_flash(:info, message) - |> redirect(to: ~p"/users/settings") - - {:error, _} -> - conn - |> put_flash(:error, "Failed to update alert preferences.") - |> redirect(to: ~p"/users/settings") - end - end def confirm_email(conn, %{"token" => token}) do case Accounts.update_user_email(conn.assigns.current_scope.user, token) do @@ -99,15 +22,4 @@ defmodule ToweropsWeb.UserSettingsController do |> redirect(to: ~p"/users/settings") end end - - defp assign_email_and_password_changesets(conn, _opts) do - user = conn.assigns.current_scope.user - - conn - |> assign(:email_changeset, Accounts.change_user_email(user)) - |> assign(:password_changeset, Accounts.change_user_password(user)) - |> assign(:credentials, Accounts.list_user_credentials(user.id)) - |> assign(:can_register_passkey, Accounts.passkey_registration_allowed?(user)) - |> assign(:mobile_sessions, Towerops.MobileSessions.list_user_sessions(user.id)) - end end diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex new file mode 100644 index 00000000..b3d89ac7 --- /dev/null +++ b/lib/towerops_web/live/user_settings_live.ex @@ -0,0 +1,417 @@ +defmodule ToweropsWeb.UserSettingsLive do + @moduledoc """ + LiveView for managing user account settings. + + Allows users to update email, password, manage passkeys, and mobile sessions. + """ + use ToweropsWeb, :live_view + + alias Towerops.Accounts + alias Towerops.MobileSessions + + @impl true + def mount(_params, _session, socket) do + socket = + socket + |> assign(:page_title, "Account Settings") + |> assign_changesets() + |> assign_credentials() + |> assign_mobile_sessions() + + {:ok, socket} + end + + @impl true + def handle_event("update_email", %{"user" => user_params}, socket) do + user = socket.assigns.current_scope.user + + case Accounts.change_user_email(user, user_params) do + %{valid?: true} = changeset -> + _ = + Accounts.deliver_user_update_email_instructions( + Ecto.Changeset.apply_action!(changeset, :insert), + user.email, + &url(~p"/users/settings/confirm-email/#{&1}") + ) + + socket = + socket + |> put_flash(:info, "A link to confirm your email change has been sent to the new address.") + |> assign_changesets() + + {:noreply, socket} + + changeset -> + {:noreply, assign(socket, :email_form, to_form(%{changeset | action: :insert}))} + end + end + + @impl true + def handle_event("update_password", %{"user" => user_params}, socket) do + user = socket.assigns.current_scope.user + + case Accounts.update_user_password(user, user_params) do + {:ok, {_updated_user, _}} -> + # Redirect to re-authenticate with new password + socket = + socket + |> put_flash(:info, "Password updated successfully. Please log in with your new password.") + |> redirect(to: ~p"/users/log-in") + + {:noreply, socket} + + {:error, changeset} -> + {:noreply, assign(socket, :password_form, to_form(changeset))} + end + end + + @impl true + def handle_event("revoke_mobile_device", %{"session-id" => session_id}, socket) do + case MobileSessions.revoke_session(session_id) do + {:ok, _} -> + socket = + socket + |> put_flash(:info, "Mobile device removed successfully.") + |> assign_mobile_sessions() + + {:noreply, socket} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to remove mobile device.")} + end + end + + @impl true + def handle_event("toggle_device_alerts", %{"session-id" => session_id, "enabled" => enabled}, socket) do + enabled_bool = enabled == "true" + + case MobileSessions.update_alert_preferences(session_id, %{alerts_enabled: enabled_bool}) do + {:ok, _} -> + message = if enabled_bool, do: "Alerts enabled for device", else: "Alerts disabled for device" + + socket = + socket + |> put_flash(:info, message) + |> assign_mobile_sessions() + + {:noreply, socket} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to update alert preferences.")} + end + end + + defp assign_changesets(socket) do + user = socket.assigns.current_scope.user + + socket + |> assign(:email_form, user |> Accounts.change_user_email() |> to_form()) + |> assign(:password_form, user |> Accounts.change_user_password() |> to_form()) + end + + defp assign_credentials(socket) do + user = socket.assigns.current_scope.user + + socket + |> assign(:credentials, Accounts.list_user_credentials(user.id)) + |> assign(:can_register_passkey, Accounts.passkey_registration_allowed?(user)) + end + + defp assign_mobile_sessions(socket) do + user = socket.assigns.current_scope.user + assign(socket, :mobile_sessions, MobileSessions.list_user_sessions(user.id)) + end + + @impl true + def render(assigns) do + ~H""" + +
+ <.header> + Account Settings + <:subtitle>Manage your account email address and password settings + +
+ + <.form + for={@email_form} + phx-submit="update_email" + id="update_email" + class="space-y-4" + > + <.input + field={@email_form[:email]} + type="email" + label="Email" + autocomplete="email" + required + /> + + <.button variant="primary" phx-disable-with="Changing...">Change Email + + +
+ + <.form + for={@password_form} + phx-submit="update_password" + id="update_password" + class="space-y-4" + > + <.input + field={@password_form[:password]} + type="password" + label="New password" + autocomplete="new-password" + required + /> + <.input + field={@password_form[:password_confirmation]} + type="password" + label="Confirm new password" + autocomplete="new-password" + required + /> + <.button variant="primary" phx-disable-with="Changing..."> + Save Password + + + +
+ +
+

+ Alert Notification Devices +

+

+ Manage mobile devices that receive push notifications for alerts +

+ +
+ <%= if Enum.empty?(@mobile_sessions) do %> +
+ <.icon name="hero-device-phone-mobile" class="mx-auto h-12 w-12 text-zinc-400" /> +

+ No mobile devices registered +

+

+ Add a mobile device to receive push notifications for alerts +

+
+ <% else %> +
+ <%= for session <- @mobile_sessions do %> +
+
+ <.icon name="hero-device-phone-mobile" class="h-5 w-5 text-zinc-400" /> +
+

+ {session.device_name || "Unknown Device"} +

+

+ {session.device_os} • {session.app_version} +

+

+ Last used {Calendar.strftime(session.last_used_at, "%B %d, %Y")} +

+
+
+
+ + +
+
+ <% end %> +
+ <% end %> + + <.link + navigate={~p"/mobile/qr-login"} + class="inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-300" + > + <.icon name="hero-qr-code" class="h-4 w-4" /> Add Mobile Device + +
+
+ +
+ +
+

+ Passkeys +

+

+ Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in +

+ + <%= if !@can_register_passkey do %> +
+

+ Please confirm your email address before registering a passkey. Check your inbox for the confirmation link. +

+
+ <% end %> + +
+ <%= if Enum.empty?(@credentials) do %> +
+ <.icon name="hero-key" class="mx-auto h-12 w-12 text-zinc-400" /> +

+ No passkeys registered +

+

+ Add a passkey to enable quick, secure sign-in with biometrics +

+
+ <% else %> +
+ <%= for credential <- @credentials do %> +
+
+ <.icon name="hero-key" class="h-5 w-5 text-zinc-400" /> +
+

+ {credential.name} +

+ <%= if credential.last_used_at do %> +

+ Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")} +

+ <% else %> +

Never used

+ <% end %> +
+
+ <.link + href={~p"/users/credentials/#{credential.id}"} + method="delete" + data-confirm="Are you sure you want to delete this passkey?" + class="text-sm font-semibold text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300" + > + Delete + +
+ <% end %> +
+ <% end %> + + <%= if @can_register_passkey do %> + + + + + <% end %> +
+
+ + """ + end +end diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 6a25ea6b..6fdf13f0 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -115,8 +115,6 @@ defmodule ToweropsWeb.Router do scope "/", ToweropsWeb do pipe_through [:browser, :require_authenticated_user] - get "/users/settings", UserSettingsController, :edit - put "/users/settings", UserSettingsController, :update get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email delete "/users/credentials/:id", UserCredentialController, :delete end @@ -155,6 +153,18 @@ defmodule ToweropsWeb.Router do ## Organization routes + live_session :require_sudo_mode, + on_mount: [ + {ToweropsWeb.UserAuth, :require_authenticated_user}, + {ToweropsWeb.UserAuth, :require_sudo_mode} + ] do + scope "/", ToweropsWeb do + pipe_through [:browser, :require_authenticated_user] + + live "/users/settings", UserSettingsLive, :index + end + end + live_session :require_authenticated_user, on_mount: [{ToweropsWeb.UserAuth, :require_authenticated_user}] do scope "/", ToweropsWeb do diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex index 7c2b9eab..6a9d39e7 100644 --- a/lib/towerops_web/user_auth.ex +++ b/lib/towerops_web/user_auth.ex @@ -414,6 +414,21 @@ defmodule ToweropsWeb.UserAuth do end end + def on_mount(:require_sudo_mode, _params, _session, socket) do + user = socket.assigns.current_scope && socket.assigns.current_scope.user + + if user && Accounts.sudo_mode?(user, -10) do + {:cont, socket} + else + socket = + socket + |> LiveView.put_flash(:error, "You must re-authenticate to access this page.") + |> LiveView.redirect(to: ~p"/users/log-in") + + {:halt, socket} + end + end + defp mount_current_scope(socket, session) do Phoenix.Component.assign_new(socket, :current_scope, fn -> build_scope_from_session(session) diff --git a/test/towerops_web/controllers/user_settings_controller_test.exs b/test/towerops_web/controllers/user_settings_controller_test.exs index 7b97ee17..41cfe507 100644 --- a/test/towerops_web/controllers/user_settings_controller_test.exs +++ b/test/towerops_web/controllers/user_settings_controller_test.exs @@ -1,4 +1,10 @@ defmodule ToweropsWeb.UserSettingsControllerTest do + @moduledoc """ + Tests for UserSettingsController. + + Most user settings functionality is now in UserSettingsLive. + This controller only handles email confirmation via token. + """ use ToweropsWeb.ConnCase, async: true import Towerops.AccountsFixtures @@ -7,99 +13,6 @@ defmodule ToweropsWeb.UserSettingsControllerTest do setup :register_and_log_in_user - describe "GET /users/settings" do - test "renders settings page", %{conn: conn} do - conn = get(conn, ~p"/users/settings") - response = html_response(conn, 200) - assert response =~ "Settings" - end - - test "redirects if user is not logged in" do - conn = build_conn() - conn = get(conn, ~p"/users/settings") - assert redirected_to(conn) == ~p"/users/log-in" - end - - @tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute) - test "redirects if user is not in sudo mode", %{conn: conn} do - conn = get(conn, ~p"/users/settings") - assert redirected_to(conn) == ~p"/users/log-in" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "You must re-authenticate to access this page." - end - end - - describe "PUT /users/settings (change password form)" do - test "updates the user password and resets tokens", %{conn: conn, user: user} do - new_password_conn = - put(conn, ~p"/users/settings", %{ - "action" => "update_password", - "user" => %{ - "password" => "new valid password", - "password_confirmation" => "new valid password" - } - }) - - assert redirected_to(new_password_conn) == ~p"/users/settings" - - assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) - - assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ - "Password updated successfully" - - assert Accounts.get_user_by_email_and_password(user.email, "new valid password") - end - - test "does not update password on invalid data", %{conn: conn} do - old_password_conn = - put(conn, ~p"/users/settings", %{ - "action" => "update_password", - "user" => %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - }) - - response = html_response(old_password_conn, 200) - assert response =~ "Settings" - assert response =~ "should be at least 12 character(s)" - assert response =~ "does not match password" - - assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token) - end - end - - describe "PUT /users/settings (change email form)" do - @tag :capture_log - test "updates the user email", %{conn: conn, user: user} do - conn = - put(conn, ~p"/users/settings", %{ - "action" => "update_email", - "user" => %{"email" => unique_user_email()} - }) - - assert redirected_to(conn) == ~p"/users/settings" - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "A link to confirm your email" - - assert Accounts.get_user_by_email(user.email) - end - - test "does not update email on invalid data", %{conn: conn} do - conn = - put(conn, ~p"/users/settings", %{ - "action" => "update_email", - "user" => %{"email" => "with spaces"} - }) - - response = html_response(conn, 200) - assert response =~ "Settings" - assert response =~ "must have the @ sign and no spaces" - end - end - describe "GET /users/settings/confirm-email/:token" do setup %{user: user} do email = unique_user_email() diff --git a/test/towerops_web/live/user_settings_live_test.exs b/test/towerops_web/live/user_settings_live_test.exs new file mode 100644 index 00000000..97562310 --- /dev/null +++ b/test/towerops_web/live/user_settings_live_test.exs @@ -0,0 +1,220 @@ +defmodule ToweropsWeb.UserSettingsLiveTest do + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + + alias Towerops.Accounts + + setup :register_and_log_in_user + + describe "User Settings LiveView" do + test "renders settings page", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/settings") + assert html =~ "Account Settings" + assert html =~ "Change Email" + assert html =~ "Save Password" + end + + test "redirects if user is not logged in" do + conn = build_conn() + result = live(conn, ~p"/users/settings") + assert {:error, {:redirect, %{to: "/users/log-in"}}} = result + end + + @tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute) + test "redirects if user is not in sudo mode", %{conn: conn} do + result = live(conn, ~p"/users/settings") + assert {:error, {:redirect, %{to: "/users/log-in", flash: %{"error" => error}}}} = result + assert error == "You must re-authenticate to access this page." + end + end + + describe "update password" do + test "updates the user password", %{conn: conn, user: user} do + {:ok, view, _html} = live(conn, ~p"/users/settings") + + # Password update redirects to login page + result = + view + |> form("#update_password", %{ + "user" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + |> render_submit() + + assert {:error, {:redirect, %{to: "/users/log-in"}}} = result + + # Verify password was actually changed + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not update password on invalid data", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/users/settings") + + result = + view + |> form("#update_password", %{ + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + |> render_submit() + + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + end + + describe "update email" do + @tag :capture_log + test "updates the user email", %{conn: conn, user: user} do + {:ok, view, _html} = live(conn, ~p"/users/settings") + new_email = unique_user_email() + + result = + view + |> form("#update_email", %{ + "user" => %{"email" => new_email} + }) + |> render_submit() + + assert result =~ "A link to confirm your email" + + # Email not changed yet until confirmed + assert Accounts.get_user_by_email(user.email) + end + + test "does not update email on invalid data", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/users/settings") + + result = + view + |> form("#update_email", %{ + "user" => %{"email" => "with spaces"} + }) + |> render_submit() + + assert result =~ "must have the @ sign and no spaces" + end + end + + describe "mobile sessions" do + test "shows empty state when no mobile sessions", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/settings") + assert html =~ "No mobile devices registered" + end + + test "shows mobile sessions list", %{conn: conn, user: user} do + # Create a mobile session + {:ok, _session} = + Towerops.MobileSessions.create_mobile_session(%{ + user_id: user.id, + device_name: "Test iPhone", + device_os: "iOS 17", + app_version: "1.0.0" + }) + + {:ok, _view, html} = live(conn, ~p"/users/settings") + assert html =~ "Test iPhone" + assert html =~ "iOS 17" + end + + test "toggles device alerts", %{conn: conn, user: user} do + {:ok, session} = + Towerops.MobileSessions.create_mobile_session(%{ + user_id: user.id, + device_name: "Test iPhone", + device_os: "iOS 17", + app_version: "1.0.0", + alerts_enabled: true + }) + + {:ok, view, _html} = live(conn, ~p"/users/settings") + + # Toggle alerts off + html = + view + |> element("button[phx-click='toggle_device_alerts'][phx-value-session-id='#{session.id}']") + |> render_click() + + assert html =~ "Alerts disabled for device" + + # Verify in database + updated_session = Towerops.MobileSessions.get_session(session.id) + refute updated_session.alerts_enabled + end + + test "revokes mobile device", %{conn: conn, user: user} do + {:ok, session} = + Towerops.MobileSessions.create_mobile_session(%{ + user_id: user.id, + device_name: "Test iPhone", + device_os: "iOS 17", + app_version: "1.0.0" + }) + + {:ok, view, _html} = live(conn, ~p"/users/settings") + + html = + view + |> element("button[phx-click='revoke_mobile_device'][phx-value-session-id='#{session.id}']") + |> render_click() + + assert html =~ "Mobile device removed successfully" + + # Verify session is gone + assert is_nil(Towerops.MobileSessions.get_session(session.id)) + end + end + + describe "confirm email with token" do + setup %{user: user} do + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{token: token, email: email} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "Email changed successfully" + + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Email change link is invalid or it has expired" + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + conn = get(conn, ~p"/users/settings/confirm-email/oops") + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Email change link is invalid or it has expired" + + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + assert redirected_to(conn) == ~p"/users/log-in" + end + end +end