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.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(:active_tab, "personal") |> 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) {:ok, socket} end @impl true def handle_event("switch_tab", %{"tab" => tab}, socket) do {:noreply, assign(socket, :active_tab, tab)} 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) socket = socket |> 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("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 @impl true def handle_event("load_more_history", _params, socket) do next_page = socket.assigns.login_history_page + 1 socket = socket |> assign(:login_history_page, next_page) |> assign_login_history() {: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 limit = 20 offset = (page - 1) * limit history = Accounts.list_user_login_history(user.id, limit: limit, offset: offset) has_more = length(history) == limit socket |> assign(:login_history, history) |> assign(:has_more_history, has_more) 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 @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" >
<% 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, @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 @has_more_history do %>
<% 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