towerops/lib/towerops_web/live/user_settings_live.ex
Graham McIntire 91e3181bbc dialyzer: fix all unmatched_return warnings (154 → 0)
Prefix fire-and-forget calls (PubSub.broadcast, Oban enqueue, Logger,
update_*, etc.) with `_ =` so dialyzer knows the return value is
intentionally discarded. Wrap a few if/case expressions whose
branches produce mixed types the same way.

No behavior changes — only explicit acknowledgement of discarded
returns.

Warnings: 242 → 88.
2026-04-21 10:03:55 -05:00

466 lines
16 KiB
Elixir

defmodule ToweropsWeb.UserSettingsLive do
@moduledoc """
LiveView for managing user account settings.
Allows users to update email, password, and mobile sessions.
"""
use ToweropsWeb, :live_view
alias Towerops.Accounts
alias Towerops.Accounts.HIBP
alias Towerops.Accounts.UserNotifier
alias Towerops.Admin.AuditLogger
alias ToweropsWeb.UserSettingsLive.ApiTokenManager
alias ToweropsWeb.UserSettingsLive.Helpers
alias ToweropsWeb.UserSettingsLive.SessionManager
alias ToweropsWeb.UserSettingsLive.TotpManager
@impl true
def mount(_params, session, socket) do
current_token = session["user_token"]
socket =
socket
|> assign(:page_title, t("Account Settings"))
|> assign(:current_token, current_token)
|> assign_changesets()
|> assign_profile_form()
|> assign_mobile_sessions()
|> assign_api_tokens()
|> assign_organizations()
|> assign_default_organization()
|> assign_browser_sessions()
|> assign_login_history()
|> assign_security_alerts()
|> assign_totp_devices()
|> assign_recovery_codes_count()
|> assign(:show_add_token_modal, false)
|> assign(:show_token_modal, false)
|> assign(:created_token, nil)
|> assign(:show_revoke_all_modal, false)
|> assign(:login_history_page, 1)
|> assign(:show_add_device_modal, false)
|> assign(:show_device_qr_modal, false)
|> assign(:show_recovery_codes_modal, false)
|> assign(:new_device_id, nil)
|> assign(:new_device_secret, nil)
|> assign(:new_device_qr_code, nil)
|> assign(:generated_recovery_codes, nil)
|> assign(:password_breach_count, nil)
|> assign(:show_delete_modal, false)
|> assign(:sole_owner_orgs, [])
|> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete))
{:ok, socket}
end
@impl true
def handle_params(params, _url, socket) do
# Default to "personal" tab if no tab param is provided
tab = Map.get(params, "tab", "personal")
# Get page from params, default to 1
page = params |> Map.get("page", "1") |> String.to_integer()
# Read modal state from URL
modal = params["modal"]
socket =
socket
|> assign(:active_tab, tab)
|> assign(:login_history_page, page)
|> assign(:show_add_token_modal, modal == "add_token")
|> assign(:show_token_modal, modal == "token_created")
|> assign(:show_revoke_all_modal, modal == "revoke_all")
|> assign(:show_add_device_modal, modal == "add_device")
|> assign(:show_device_qr_modal, modal == "device_qr")
|> assign(:show_recovery_codes_modal, modal == "recovery_codes")
|> assign(:show_delete_modal, modal == "delete_account")
|> assign_login_history()
{:noreply, socket}
end
@impl true
def handle_event("show_add_token_modal", _params, socket) do
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "add_token"}))}
end
@impl true
def handle_event("cancel_add_token", _params, socket) do
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
def handle_event("update_profile", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
case Accounts.update_user_profile(user, user_params) do
{:ok, updated_user} ->
# Log profile update
fields_changed = Map.keys(user_params)
AuditLogger.log_user_profile_updated(nil, user.id, fields_changed)
# Update current_scope with new user data (including timezone and time_format)
updated_scope = %{
socket.assigns.current_scope
| user: updated_user,
timezone: updated_user.timezone || "UTC",
time_format: updated_user.time_format || "24h"
}
socket =
socket
|> assign(:current_scope, updated_scope)
|> put_flash(:info, t("Profile updated successfully."))
|> assign_profile_form()
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, :profile_form, to_form(changeset))}
end
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, t("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("check_password_breach", %{"value" => password}, socket)
when is_binary(password) and byte_size(password) > 0 do
case HIBP.check_password(password) do
{:ok, count} when is_integer(count) ->
{:noreply, assign(socket, :password_breach_count, count)}
{:ok, :unknown} ->
{:noreply, assign(socket, :password_breach_count, nil)}
end
end
def handle_event("check_password_breach", _params, socket) do
{:noreply, socket}
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, t("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
# Mobile session management - delegated to SessionManager
def handle_event("revoke_mobile_device", %{"session-id" => session_id}, socket) do
{:noreply, SessionManager.revoke_mobile_device(socket, session_id)}
end
@impl true
def handle_event("toggle_device_alerts", %{"session-id" => session_id, "enabled" => enabled}, socket) do
{:noreply, SessionManager.toggle_device_alerts(socket, session_id, enabled)}
end
# API token management - delegated to ApiTokenManager
@impl true
def handle_event("create_api_token", params, socket) do
socket = ApiTokenManager.create_api_token(socket, params)
# After creating token, show the token_created modal
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "token_created"}))}
end
@impl true
def handle_event("close_token_modal", _params, socket) do
socket = ApiTokenManager.close_token_modal(socket)
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
def handle_event("delete_api_token", %{"token-id" => token_id}, socket) do
{:noreply, ApiTokenManager.delete_api_token(socket, token_id)}
end
# Browser session management - delegated to SessionManager
@impl true
def handle_event("revoke_session", %{"session-id" => session_id}, socket) do
{:noreply, SessionManager.revoke_session(socket, session_id)}
end
@impl true
def handle_event("show_revoke_all_modal", _params, socket) do
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "revoke_all"}))}
end
@impl true
def handle_event("cancel_revoke_all", _params, socket) do
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
def handle_event("confirm_revoke_all", _params, socket) do
socket = SessionManager.confirm_revoke_all(socket)
# After revoking all sessions, close modal
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
# Security tab - TOTP Device Management
@impl true
# TOTP device management - delegated to TotpManager
def handle_event("show_add_device_modal", _params, socket) do
socket = TotpManager.show_add_device_modal(socket)
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "add_device"}))}
end
@impl true
def handle_event("cancel_add_device", _params, socket) do
socket = TotpManager.cancel_add_device(socket)
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
def handle_event("create_device", %{"name" => name}, socket) do
socket = TotpManager.create_device(socket, name)
# After creating device, show the QR code modal
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "device_qr"}))}
end
@impl true
def handle_event("verify_new_device", %{"code" => code}, socket) do
socket = TotpManager.verify_new_device(socket, code)
# After verifying device, close modal
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
def handle_event("close_device_qr_modal", _params, socket) do
socket = TotpManager.close_device_qr_modal(socket)
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
def handle_event("delete_device", %{"device-id" => device_id}, socket) do
{:noreply, TotpManager.delete_device(socket, device_id)}
end
# Account deletion
@impl true
def handle_event("show_delete_modal", _params, socket) do
user = socket.assigns.current_scope.user
sole_owner_orgs = Accounts.sole_owner_organizations(user.id)
socket =
socket
|> assign(:sole_owner_orgs, sole_owner_orgs)
|> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete))
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "delete_account"}))}
end
@impl true
def handle_event("close_delete_modal", _params, socket) do
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
def handle_event("delete_account", %{"password" => password, "confirmation" => confirmation}, socket) do
user = socket.assigns.current_scope.user
if confirmation == "DELETE" do
case Accounts.delete_account(user, password) do
{:ok, _} ->
# Send farewell email after deletion
_ = UserNotifier.deliver_account_deleted_notification(user.email, user.first_name)
socket =
socket
|> put_flash(:info, t("Your account has been permanently deleted."))
|> redirect(to: ~p"/")
{:noreply, socket}
{:error, :invalid_password} ->
socket =
socket
|> put_flash(:error, t("Incorrect password"))
|> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete))
{:noreply, socket}
{:error, :sole_owner, _org_names} ->
socket = put_flash(socket, :error, t("Cannot delete account while you are the sole owner of an organization."))
{:noreply, socket}
end
else
socket =
socket
|> put_flash(:error, t("Please type DELETE to confirm"))
|> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete))
{:noreply, socket}
end
end
# Recovery codes - delegated to TotpManager
@impl true
def handle_event("regenerate_recovery_codes", _params, socket) do
socket = TotpManager.regenerate_recovery_codes(socket)
# After regenerating codes, show the recovery codes modal
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "recovery_codes"}))}
end
@impl true
def handle_event("close_recovery_codes_modal", _params, socket) do
socket = TotpManager.close_recovery_codes_modal(socket)
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
defp get_current_token_id(%{current_token: token}) when is_binary(token) do
Accounts.get_user_token_id_by_value(token)
end
defp get_current_token_id(_), do: nil
defp assign_profile_form(socket) do
user = socket.assigns.current_scope.user
assign(socket, :profile_form, user |> Accounts.change_user_profile() |> to_form())
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
# Assignment helpers delegated to helper modules
defdelegate assign_mobile_sessions(socket), to: SessionManager
defdelegate assign_api_tokens(socket), to: ApiTokenManager
defdelegate assign_browser_sessions(socket), to: SessionManager
defdelegate assign_totp_devices(socket), to: TotpManager
defdelegate assign_recovery_codes_count(socket), to: TotpManager
defp assign_organizations(socket) do
user = socket.assigns.current_scope.user
assign(socket, :organizations, Towerops.Organizations.list_user_organizations(user.id))
end
defp assign_default_organization(socket) do
user = socket.assigns.current_scope.user
organizations = socket.assigns.organizations
# Get user's preferred default org, or fall back to first org
default_org =
if user.default_organization_id do
Enum.find(organizations, &(&1.id == user.default_organization_id)) || List.first(organizations)
else
List.first(organizations)
end
assign(socket, :default_organization, default_org)
end
defp assign_login_history(socket) do
user = socket.assigns.current_scope.user
page = socket.assigns[:login_history_page] || 1
per_page = 5
offset = (page - 1) * per_page
# Get total count and history for current page
total_count = Accounts.count_user_login_attempts(user.id)
history = Accounts.list_user_login_history(user.id, limit: per_page, offset: offset)
total_pages = ceil(total_count / per_page)
page = max(1, min(page, max(1, total_pages)))
socket
|> assign(:login_history, history)
|> assign(:login_history_pagination, %{
page: page,
per_page: per_page,
total_count: total_count,
total_pages: total_pages
})
end
defp assign_security_alerts(socket) do
user = socket.assigns.current_scope.user
since = DateTime.add(DateTime.utc_now(), -24 * 60 * 60, :second)
failed_count = Accounts.count_user_login_attempts(user.id, success: false, since: since)
socket
|> assign(:failed_login_count, failed_count)
|> assign(:show_security_alert, failed_count >= 3)
end
# URL building helper
defp build_settings_path(socket, extra_params) do
# Preserve current tab and page
base_params = %{
"tab" => socket.assigns.active_tab,
"page" => to_string(socket.assigns.login_history_page)
}
# Merge with extra params, removing nil values
params =
base_params
|> Map.merge(extra_params)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
# Remove page param if it's 1 (default)
params = if params["page"] == "1", do: Map.delete(params, "page"), else: params
# Build path with query string
if map_size(params) > 0 do
~p"/users/settings?#{params}"
else
~p"/users/settings"
end
end
# Sessions tab helper functions
# Formatting helpers delegated to ToweropsWeb.UserSettingsLive.Helpers module
defdelegate current_session?(session, current_token_id), to: Helpers
defdelegate format_timestamp_in_timezone(datetime, timezone, time_format \\ "24h"), to: Helpers
defdelegate format_browser_info(session), to: Helpers
defdelegate format_location(record), to: Helpers
defdelegate format_relative_time(datetime), to: Helpers
defdelegate login_method_info(method), to: Helpers
defdelegate pagination_range(current_page, total_pages), to: Helpers
end