towerops/lib/towerops_web/live/user_settings_live.ex
Graham McIntire 7068ab2466
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)
2026-01-15 16:29:50 -06:00

417 lines
16 KiB
Elixir

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"
>
&#8203;
</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