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.
466 lines
16 KiB
Elixir
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
|