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 socket = socket |> assign(:page_title, "Account Settings") |> assign(:active_tab, "personal") |> assign_changesets() |> assign_profile_form() |> assign_credentials() |> assign_mobile_sessions() |> assign_api_tokens() |> assign_organizations() |> assign_default_organization() |> assign(:show_add_token_modal, false) |> assign(:show_token_modal, false) |> assign(:created_token, 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} -> 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 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 @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 @show_add_token_modal do %> <% end %> <%= if @show_token_modal && @created_token do %> <% end %>
""" end end