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.Accounts.HIBP alias Towerops.Accounts.UserTotpDevice alias Towerops.Admin.AuditLogger alias Towerops.MobileSessions @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 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 @impl true def handle_event("create_api_token", params, socket) do user = socket.assigns.current_scope.user # Handle both nested token params and flat params {name, org_id} = case params do %{"token" => %{"name" => n, "organization_id" => o}} -> {n, o} %{"name" => n, "organization_id" => o} -> {n, o} _ -> {nil, nil} end if name && org_id do case Towerops.ApiTokens.create_api_token(%{ name: name, organization_id: org_id, user_id: user.id }) do {:ok, {_token, raw_token}} -> socket = socket |> assign(:show_add_token_modal, false) |> assign(:created_token, raw_token) |> assign(:show_token_modal, true) |> assign_api_tokens() {:noreply, socket} {:error, _changeset} -> {:noreply, put_flash(socket, :error, "Failed to create API token.")} end else {:noreply, put_flash(socket, :error, "Name and organization are required.")} end end @impl true def handle_event("close_token_modal", _params, socket) do socket = socket |> assign(:show_token_modal, false) |> assign(:created_token, nil) {:noreply, socket} end @impl true def handle_event("delete_api_token", %{"token-id" => token_id}, socket) do token = Towerops.ApiTokens.get_api_token!(token_id) user = socket.assigns.current_scope.user if token.user_id == user.id do case Towerops.ApiTokens.delete_api_token(token) do {:ok, _} -> socket = socket |> put_flash(:info, "API token deleted successfully.") |> assign_api_tokens() {:noreply, socket} {:error, _} -> {:noreply, put_flash(socket, :error, "Failed to delete API token.")} end else {:noreply, put_flash(socket, :error, "You can only delete your own tokens.")} end end @impl true def handle_event("revoke_session", %{"session-id" => session_id}, socket) do user = socket.assigns.current_scope.user current_token_id = get_current_token_id(socket) case Accounts.revoke_browser_session(session_id, user.id, current_token_id) do {:ok, _} -> socket = socket |> put_flash(:info, "Session revoked successfully.") |> assign_browser_sessions() {:noreply, socket} {:error, :self_revoke} -> {:noreply, put_flash(socket, :error, "You cannot revoke your current session.")} {:error, :not_found} -> {:noreply, put_flash(socket, :error, "Session not found.")} {:error, _} -> {:noreply, put_flash(socket, :error, "Failed to revoke session.")} end end @impl true def handle_event("show_revoke_all_modal", _params, socket) do {:noreply, assign(socket, :show_revoke_all_modal, true)} end @impl true def handle_event("cancel_revoke_all", _params, socket) do {:noreply, assign(socket, :show_revoke_all_modal, false)} end @impl true def handle_event("confirm_revoke_all", _params, socket) do user = socket.assigns.current_scope.user current_token_id = get_current_token_id(socket) {count, _} = Accounts.revoke_all_other_sessions(user.id, current_token_id) socket = socket |> put_flash(:info, "Revoked #{count} other session(s) successfully.") |> assign(:show_revoke_all_modal, false) |> assign_browser_sessions() {:noreply, socket} end # Security tab - TOTP Device Management @impl true def handle_event("show_add_device_modal", _params, socket) do {:noreply, assign(socket, :show_add_device_modal, true)} end @impl true def handle_event("cancel_add_device", _params, socket) do {:noreply, assign(socket, :show_add_device_modal, false)} end @impl true def handle_event("create_device", %{"name" => name}, socket) do user = socket.assigns.current_scope.user case Accounts.create_totp_device(user.id, name) do {:ok, device, secret} -> qr_code = Accounts.generate_totp_qr_code(user, secret) socket = socket |> assign(:show_add_device_modal, false) |> assign(:show_device_qr_modal, true) |> assign(:new_device_id, device.id) |> assign(:new_device_secret, secret) |> assign(:new_device_qr_code, qr_code) {:noreply, socket} {:error, _changeset} -> {:noreply, put_flash(socket, :error, "Failed to create device.")} end end @impl true def handle_event("verify_new_device", %{"code" => code}, socket) do secret = socket.assigns.new_device_secret device_id = socket.assigns.new_device_id if Accounts.verify_totp(secret, code) do # Device already created, just mark as verified by touching it device = Towerops.Repo.get!(UserTotpDevice, device_id) device |> UserTotpDevice.touch_changeset() |> Towerops.Repo.update!() socket = socket |> put_flash(:info, "Device added successfully!") |> assign(:show_device_qr_modal, false) |> assign(:new_device_id, nil) |> assign(:new_device_secret, nil) |> assign(:new_device_qr_code, nil) |> assign_totp_devices() {:noreply, socket} else {:noreply, put_flash(socket, :error, "Invalid code. Please try again.")} end end @impl true def handle_event("close_device_qr_modal", _params, socket) do # Delete the unverified device if user cancels if device_id = socket.assigns.new_device_id do user = socket.assigns.current_scope.user Accounts.delete_totp_device(device_id, user.id) end socket = socket |> assign(:show_device_qr_modal, false) |> assign(:new_device_id, nil) |> assign(:new_device_secret, nil) |> assign(:new_device_qr_code, nil) |> assign_totp_devices() {:noreply, socket} end @impl true def handle_event("delete_device", %{"device-id" => device_id}, socket) do user = socket.assigns.current_scope.user case Accounts.delete_totp_device(device_id, user.id) do {:ok, _} -> socket = socket |> put_flash(:info, "Device removed successfully.") |> assign_totp_devices() {:noreply, socket} {:error, :last_device} -> {:noreply, put_flash(socket, :error, "Cannot remove last device. You must have at least one.")} {:error, _} -> {:noreply, put_flash(socket, :error, "Failed to remove device.")} end end # Security tab - Recovery Codes @impl true def handle_event("regenerate_recovery_codes", _params, socket) do user = socket.assigns.current_scope.user case Accounts.generate_recovery_codes(user.id) do {:ok, codes} -> socket = socket |> assign(:show_recovery_codes_modal, true) |> assign(:generated_recovery_codes, codes) |> assign_recovery_codes_count() {:noreply, socket} {:error, _} -> {:noreply, put_flash(socket, :error, "Failed to generate recovery codes.")} end end @impl true def handle_event("close_recovery_codes_modal", _params, socket) do socket = socket |> assign(:show_recovery_codes_modal, false) |> assign(:generated_recovery_codes, nil) {:noreply, 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 defp assign_mobile_sessions(socket) do user = socket.assigns.current_scope.user assign(socket, :mobile_sessions, MobileSessions.list_user_sessions(user.id)) end defp assign_api_tokens(socket) do user = socket.assigns.current_scope.user assign(socket, :api_tokens, Towerops.ApiTokens.list_user_api_tokens(user.id)) end 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_browser_sessions(socket) do user = socket.assigns.current_scope.user sessions = Accounts.list_active_browser_sessions(user.id) assign(socket, :browser_sessions, sessions) 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 defp assign_totp_devices(socket) do user = socket.assigns.current_scope.user devices = Accounts.list_user_totp_devices(user.id) assign(socket, :totp_devices, devices) end defp assign_recovery_codes_count(socket) do user = socket.assigns.current_scope.user count = Accounts.count_unused_recovery_codes(user.id) assign(socket, :unused_recovery_codes_count, count) end # Sessions tab helper functions defp current_session?(session, current_token_id) do session.user_token_id == current_token_id end defp format_timestamp_in_timezone(datetime, timezone) do case DateTime.shift_zone(datetime, timezone) do {:ok, shifted} -> Calendar.strftime(shifted, "%b %d, %Y %I:%M %p %Z") {:error, _} -> # Fallback to UTC if timezone conversion fails Calendar.strftime(datetime, "%b %d, %Y %I:%M %p UTC") end end defp format_browser_info(session) do case {session.browser_name, session.browser_version, session.os_name} do {nil, _, _} -> session.device_name || "Unknown Browser" {browser, nil, nil} -> browser {browser, version, nil} -> "#{browser} #{version}" {browser, nil, os} -> "#{browser} on #{os}" {browser, version, os} -> "#{browser} #{version} on #{os}" end end defp format_location(record) do case {record.city_name, record.subdivision_1_name, record.country_name} do {nil, nil, nil} -> "Unknown Location" {city, state, country} when not is_nil(city) and not is_nil(state) and not is_nil(country) -> "#{city}, #{state}, #{country}" {city, nil, country} when not is_nil(city) and not is_nil(country) -> "#{city}, #{country}" {nil, nil, country} when not is_nil(country) -> country _ -> "Unknown Location" end end defp format_relative_time(datetime) do now = DateTime.utc_now() diff = DateTime.diff(now, datetime, :second) cond do diff < 60 -> "Just now" diff < 3600 -> "#{div(diff, 60)} minutes ago" diff < 86_400 -> "#{div(diff, 3600)} hours ago" diff < 604_800 -> "#{div(diff, 86_400)} days ago" true -> Calendar.strftime(datetime, "%b %d, %Y") end end defp login_method_info(method) do case method do "password" -> {"hero-lock-closed", "Password"} "magic_link" -> {"hero-envelope", "Magic Link"} "webauthn" -> {"hero-key", "Passkey"} "mobile_qr" -> {"hero-device-phone-mobile", "Mobile QR"} _ -> {"hero-question-mark-circle", method} end end # Generate pagination range with ellipsis for large page counts # Shows: [1] ... [4] [5] [6] ... [20] when on page 5 of 20 defp pagination_range(current_page, total_pages) do cond do total_pages <= 7 -> # Show all pages if 7 or fewer Enum.to_list(1..total_pages) current_page <= 4 -> # Near the start [1, 2, 3, 4, 5, :ellipsis, total_pages] current_page >= total_pages - 3 -> # Near the end [1, :ellipsis, total_pages - 4, total_pages - 3, total_pages - 2, total_pages - 1, total_pages] true -> # In the middle [1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages] end end @impl true def render(assigns) do ~H"""

Account Settings

Manage your account email address, password, and security settings

<%= if @active_tab == "personal" do %>

Personal Information

Update your name and timezone preferences.

<.form for={@profile_form} phx-submit="update_profile" id="update_profile" class="md:col-span-2" >
<% end %> <%= if @active_tab == "account" do %>

Email Address

Update your email address. You'll receive a confirmation link to verify the new address.

<.form for={@email_form} phx-submit="update_email" id="update_email" class="md:col-span-2" >

Change Password

Update your password. You'll be logged out and need to sign in again with your new password.

<.form for={@password_form} phx-submit="update_password" id="update_password" class="md:col-span-2" >
<%= if @password_breach_count && @password_breach_count > 0 do %>
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />

Password found in data breaches

This password has appeared {@password_breach_count} {if @password_breach_count == 1, do: "time", else: "times"} in known data breaches. We strongly recommend choosing a different, more unique password to protect your account.

<% end %>
<% end %> <%= if @active_tab == "api" do %>

API Tokens

Create API tokens to access Towerops programmatically. Tokens are scoped to a specific organization.

<%= if Enum.empty?(@api_tokens) do %>
<.icon name="hero-code-bracket" class="mx-auto h-12 w-12 text-gray-400" />

No API tokens

Get started by creating a new API token.

<% else %>
    <%= for token <- @api_tokens do %>
  • {token.name}

    {token.organization.name}

    <%= if token.last_used_at do %> Last used {Calendar.strftime(token.last_used_at, "%B %d, %Y")} <% else %> Never used <% end %>

  • <% end %>
<% end %>
<% end %> <%= if @active_tab == "notifications" do %>

Mobile Devices

Manage mobile devices that receive push notifications for alerts.

<%= if Enum.empty?(@mobile_sessions) do %>
<.icon name="hero-device-phone-mobile" class="mx-auto h-12 w-12 text-gray-400" />

No mobile devices

Add a mobile device to receive push notifications for alerts.

<.link navigate={~p"/mobile/qr-login"} class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400" > <.icon name="hero-qr-code" class="-ml-0.5 h-5 w-5" /> Add Device
<% else %>
    <%= for session <- @mobile_sessions do %>
  • {session.device_name || "Unknown Device"}

    <%= if session.alerts_enabled do %> <.icon name="hero-bell" class="h-3 w-3" /> Alerts On <% else %> <.icon name="hero-bell-slash" class="h-3 w-3" /> Alerts Off <% end %>

    {session.device_os} • {session.app_version}

    Last used {Calendar.strftime(session.last_used_at, "%B %d, %Y")}

  • <% end %>
<.link navigate={~p"/mobile/qr-login"} class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400" > <.icon name="hero-qr-code" class="-ml-0.5 h-5 w-5" /> Add Device
<% end %>
<% end %> <%= if @active_tab == "sessions" do %> <%= if @show_security_alert do %>
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" />

Security Alert

We detected {@failed_login_count} failed login attempts in the last 24 hours. Please review your login history below and ensure all activity is authorized.

<% end %>

Active Sessions

Manage browser sessions where you're currently logged in.

<%= if Enum.empty?(@browser_sessions) do %>
<.icon name="hero-computer-desktop" class="mx-auto h-12 w-12 text-gray-400" />

No active sessions

Your current session will appear here after your next login.

<% else %>
    <%= for session <- @browser_sessions do %> <% is_current = current_session?(session, get_current_token_id(assigns)) %>
  • {format_browser_info(session)}

    <%= if is_current do %> Current Session <% end %>
    <.icon name="hero-map-pin" class="h-3 w-3" /> {format_location(session)} {session.ip_address}
    <.icon name="hero-clock" class="h-3 w-3" /> Last active {format_relative_time(session.last_activity_at)}
  • <% end %>
<%= if length(@browser_sessions) > 1 do %>
<% end %> <% end %>

Login History

Recent login attempts to your account, including successful and failed attempts.

<%= if Enum.empty?(@login_history) do %>
<.icon name="hero-clock" class="mx-auto h-12 w-12 text-gray-400" />

No login history

Your login attempts will appear here.

<% else %>
<%= for attempt <- @login_history do %> <% end %>
Date & Time Status Method Location IP Address
{format_timestamp_in_timezone( attempt.inserted_at, @current_scope.timezone )} <%= if attempt.success do %> <.icon name="hero-check-circle" class="h-3 w-3" /> Success <% else %> <.icon name="hero-x-circle" class="h-3 w-3" /> Failed <% end %> <% {icon, text} = login_method_info(attempt.method) %> <.icon name={icon} class="h-3 w-3" /> {text} {format_location(attempt)} {attempt.ip_address}
<%= if @login_history_pagination.total_pages > 1 do %>
<.link :if={@login_history_pagination.page > 1} patch={ ~p"/users/settings?tab=#{@active_tab}&page=#{@login_history_pagination.page - 1}" } class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" > Previous <.link :if={@login_history_pagination.page < @login_history_pagination.total_pages} patch={ ~p"/users/settings?tab=#{@active_tab}&page=#{@login_history_pagination.page + 1}" } class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" > Next
<% end %> <% end %>
<% end %> <%= if @active_tab == "security" do %>

Authenticator Apps

Manage TOTP devices for two-factor authentication. You must have at least one device configured.

<%= if Enum.empty?(@totp_devices) do %>
<.icon name="hero-device-phone-mobile" class="mx-auto h-12 w-12 text-gray-400" />

No authenticator apps

Add your first authenticator app to enable two-factor authentication.

<% else %>
    <%= for device <- @totp_devices do %>
  • {device.name}

    Added {Calendar.strftime(device.inserted_at, "%B %d, %Y")}

    <%= if device.last_used_at do %>

    Last used {format_relative_time(device.last_used_at)}

    <% end %>
  • <% end %>
<% end %>

Recovery Codes

Single-use backup codes for account access if you lose your authenticator app.

Available Recovery Codes

<%= if @unused_recovery_codes_count == 0 do %> No recovery codes available - Generate new codes immediately <% else %> You have {@unused_recovery_codes_count} unused recovery code{if @unused_recovery_codes_count != 1, do: "s"} <% end %>

<%= if @unused_recovery_codes_count == 0 do %>
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" />

No Recovery Codes

You have no recovery codes available. If you lose access to your authenticator app, you won't be able to log in. Generate new codes now.

<% end %>
<% end %>
<%= if @show_revoke_all_modal do %> <% end %> <%= if @show_add_token_modal do %> <% end %> <%= if @show_token_modal && @created_token do %> <% end %> <%= if @show_add_device_modal do %> <% end %> <%= if @show_device_qr_modal && @new_device_qr_code do %> <% end %> <%= if @show_recovery_codes_modal && @generated_recovery_codes do %> <% end %>
""" end end