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.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_credentials() |> assign_mobile_sessions() |> assign_api_tokens() |> assign_organizations() |> assign_default_organization() |> assign_browser_sessions() |> assign_login_history() |> assign_security_alerts() |> 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) {: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} -> 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 defp get_current_token_id(socket) do token = socket.assigns.current_token if token do Accounts.get_user_token_id_by_value(token) end end 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_credentials(socket) do user = socket.assigns.current_scope.user socket |> assign(:credentials, Accounts.list_user_credentials(user.id)) |> assign(:can_register_passkey, Accounts.passkey_registration_allowed?(user)) 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 # Sessions tab helper functions defp current_session?(session, current_token_id) do session.user_token_id == current_token_id 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, avatar, and timezone preferences.

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

Enter a URL to an avatar image or use a service like Gravatar.

<% 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 == "security" do %>

Passkeys

Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in.

<%= if !@can_register_passkey do %>

Please confirm your email address before registering a passkey. Check your inbox for the confirmation link.

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

No passkeys

Add a passkey to enable quick, secure sign-in with biometrics.

<%= if @can_register_passkey do %>
<% end %>
<% else %>
    <%= for credential <- @credentials do %>
  • {credential.name}

    <%= if credential.last_used_at do %> Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")} <% else %> Never used <% end %>
    <.link href={~p"/users/credentials/#{credential.id}"} method="delete" data-confirm="Are you sure you want to delete this passkey?" class="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-100 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/10 dark:hover:bg-white/20" > Delete
  • <% end %>
<%= if @can_register_passkey do %>
<% end %> <% 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
{Calendar.strftime(attempt.inserted_at, "%b %d, %Y %I:%M %p")} <%= 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 @show_revoke_all_modal do %> <% end %> <%= if @show_add_token_modal do %> <% end %> <%= if @show_token_modal && @created_token do %> <% end %>
""" end end