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