Convert User Settings from controller to LiveView
- Create UserSettingsLive to replace UserSettingsController - Convert email/password forms to LiveView with proper form handling - Add mobile session management (toggle alerts, revoke devices) - Add require_sudo_mode on_mount hook to UserAuth - Create dedicated live_session for sudo mode routes in router - Keep UserSettingsController for email confirmation via token only - Add MobileSessions.get_session/1 function for test support - Update tests to match LiveView behavior (password update redirects to login) - Remove old controller tests for email/password updates (now in LiveView)
This commit is contained in:
parent
1786198c11
commit
7068ab2466
7 changed files with 683 additions and 189 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
417
lib/towerops_web/live/user_settings_live.ex
Normal file
417
lib/towerops_web/live/user_settings_live.ex
Normal file
|
|
@ -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"""
|
||||
<Layouts.authenticated flash={@flash} current_scope={@current_scope} current_organization={nil}>
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Account Settings
|
||||
<:subtitle>Manage your account email address and password settings</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<.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</.button>
|
||||
</.form>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<.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
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100">
|
||||
Alert Notification Devices
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Manage mobile devices that receive push notifications for alerts
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<%= if Enum.empty?(@mobile_sessions) do %>
|
||||
<div class="rounded-lg border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
|
||||
<.icon name="hero-device-phone-mobile" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
No mobile devices registered
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Add a mobile device to receive push notifications for alerts
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-3">
|
||||
<%= for session <- @mobile_sessions do %>
|
||||
<div class="flex items-center justify-between rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<.icon name="hero-device-phone-mobile" class="h-5 w-5 text-zinc-400" />
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{session.device_name || "Unknown Device"}
|
||||
</p>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{session.device_os} • {session.app_version}
|
||||
</p>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Last used {Calendar.strftime(session.last_used_at, "%B %d, %Y")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_device_alerts"
|
||||
phx-value-session-id={session.id}
|
||||
phx-value-enabled={if session.alerts_enabled, do: "false", else: "true"}
|
||||
class={[
|
||||
"inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold shadow-sm",
|
||||
if(session.alerts_enabled,
|
||||
do:
|
||||
"bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/20 dark:text-green-400 dark:hover:bg-green-900/30",
|
||||
else:
|
||||
"bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= if session.alerts_enabled do %>
|
||||
<.icon name="hero-bell" class="h-4 w-4" /> Alerts On
|
||||
<% else %>
|
||||
<.icon name="hero-bell-slash" class="h-4 w-4" /> Alerts Off
|
||||
<% end %>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="revoke_mobile_device"
|
||||
phx-value-session-id={session.id}
|
||||
data-confirm="Are you sure you want to remove this device?"
|
||||
class="text-sm font-semibold text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% 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
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100">
|
||||
Passkeys
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in
|
||||
</p>
|
||||
|
||||
<%= if !@can_register_passkey do %>
|
||||
<div class="mt-4 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||
Please confirm your email address before registering a passkey. Check your inbox for the confirmation link.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<%= if Enum.empty?(@credentials) do %>
|
||||
<div class="rounded-lg border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
|
||||
<.icon name="hero-key" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
No passkeys registered
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Add a passkey to enable quick, secure sign-in with biometrics
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-3">
|
||||
<%= for credential <- @credentials do %>
|
||||
<div class="flex items-center justify-between rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<.icon name="hero-key" class="h-5 w-5 text-zinc-400" />
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{credential.name}
|
||||
</p>
|
||||
<%= if credential.last_used_at do %>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")}
|
||||
</p>
|
||||
<% else %>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">Never used</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<.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
|
||||
</.link>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @can_register_passkey do %>
|
||||
<button
|
||||
type="button"
|
||||
id="add-passkey-btn"
|
||||
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-plus" class="h-4 w-4" /> Add Passkey
|
||||
</button>
|
||||
|
||||
<!-- Passkey Registration Modal -->
|
||||
<div
|
||||
id="passkey-modal"
|
||||
class="hidden fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
|
||||
<!-- Background overlay -->
|
||||
<div
|
||||
class="fixed inset-0 z-0 bg-zinc-500 bg-opacity-75 transition-opacity dark:bg-zinc-950 dark:bg-opacity-75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Center modal -->
|
||||
<span
|
||||
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</span>
|
||||
|
||||
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-zinc-900 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
||||
<div>
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
||||
<.icon name="hero-key" class="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<h3
|
||||
class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100"
|
||||
id="modal-title"
|
||||
>
|
||||
Add Passkey
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Give this passkey a name to help you identify it later (e.g., "MacBook Touch ID", "iPhone").
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<input
|
||||
type="text"
|
||||
id="passkey-name-input"
|
||||
placeholder="e.g., MacBook Touch ID"
|
||||
class="block w-full rounded-lg border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="passkey-error"
|
||||
class="mt-3 hidden rounded-lg bg-red-50 p-3 dark:bg-red-900/20"
|
||||
>
|
||||
<p class="text-sm text-red-800 dark:text-red-200"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
id="confirm-add-passkey"
|
||||
class="inline-flex w-full justify-center rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed sm:col-start-2"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="cancel-add-passkey"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-zinc-900 shadow-sm ring-1 ring-inset ring-zinc-300 hover:bg-zinc-50 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700 dark:hover:bg-zinc-700 sm:col-start-1 sm:mt-0"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
220
test/towerops_web/live/user_settings_live_test.exs
Normal file
220
test/towerops_web/live/user_settings_live_test.exs
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue