338 lines
11 KiB
Elixir
338 lines
11 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.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, "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)
|
|
|
|
{: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()
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:active_tab, tab)
|
|
|> assign(:login_history_page, page)
|
|
|> assign_login_history()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("show_add_token_modal", _params, socket) do
|
|
{:noreply, assign(socket, :show_add_token_modal, true)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("cancel_add_token", _params, socket) do
|
|
{:noreply, assign(socket, :show_add_token_modal, false)}
|
|
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)
|
|
updated_scope = %{socket.assigns.current_scope | user: updated_user, timezone: updated_user.timezone || "UTC"}
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:current_scope, updated_scope)
|
|
|> put_flash(:info, "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, "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, "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
|
|
{:noreply, ApiTokenManager.create_api_token(socket, params)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("close_token_modal", _params, socket) do
|
|
{:noreply, ApiTokenManager.close_token_modal(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, SessionManager.show_revoke_all_modal(socket)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("cancel_revoke_all", _params, socket) do
|
|
{:noreply, SessionManager.cancel_revoke_all(socket)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("confirm_revoke_all", _params, socket) do
|
|
{:noreply, SessionManager.confirm_revoke_all(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
|
|
{:noreply, TotpManager.show_add_device_modal(socket)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("cancel_add_device", _params, socket) do
|
|
{:noreply, TotpManager.cancel_add_device(socket)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("create_device", %{"name" => name}, socket) do
|
|
{:noreply, TotpManager.create_device(socket, name)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("verify_new_device", %{"code" => code}, socket) do
|
|
{:noreply, TotpManager.verify_new_device(socket, code)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("close_device_qr_modal", _params, socket) do
|
|
{:noreply, TotpManager.close_device_qr_modal(socket)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("delete_device", %{"device-id" => device_id}, socket) do
|
|
{:noreply, TotpManager.delete_device(socket, device_id)}
|
|
end
|
|
|
|
# Recovery codes - delegated to TotpManager
|
|
@impl true
|
|
def handle_event("regenerate_recovery_codes", _params, socket) do
|
|
{:noreply, TotpManager.regenerate_recovery_codes(socket)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("close_recovery_codes_modal", _params, socket) do
|
|
{:noreply, TotpManager.close_recovery_codes_modal(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
|
|
organizations = socket.assigns.organizations
|
|
default_org = List.first(organizations)
|
|
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
|
|
|
|
# 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), 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
|