From 1414f087fc04df6d72359cb3c08797e21af67ef1 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 1 Feb 2026 17:25:16 -0600 Subject: [PATCH] bug fix --- lib/towerops_web/live/user_settings_live.ex | 2043 +---------------- .../live/user_settings_live.html.heex | 1660 ++++++++++++++ .../user_settings_live/api_token_manager.ex | 81 + .../live/user_settings_live/helpers.ex | 115 + .../user_settings_live/session_manager.ex | 121 + .../live/user_settings_live/totp_manager.ex | 158 ++ 6 files changed, 2175 insertions(+), 2003 deletions(-) create mode 100644 lib/towerops_web/live/user_settings_live.html.heex create mode 100644 lib/towerops_web/live/user_settings_live/api_token_manager.ex create mode 100644 lib/towerops_web/live/user_settings_live/helpers.ex create mode 100644 lib/towerops_web/live/user_settings_live/session_manager.ex create mode 100644 lib/towerops_web/live/user_settings_live/totp_manager.ex diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex index bd80c215..f5335f2f 100644 --- a/lib/towerops_web/live/user_settings_live.ex +++ b/lib/towerops_web/live/user_settings_live.ex @@ -8,9 +8,11 @@ defmodule ToweropsWeb.UserSettingsLive do alias Towerops.Accounts alias Towerops.Accounts.HIBP - alias Towerops.Accounts.UserTotpDevice alias Towerops.Admin.AuditLogger - alias Towerops.MobileSessions + alias ToweropsWeb.UserSettingsLive.ApiTokenManager + alias ToweropsWeb.UserSettingsLive.Helpers + alias ToweropsWeb.UserSettingsLive.SessionManager + alias ToweropsWeb.UserSettingsLive.TotpManager @impl true def mount(_params, session, socket) do @@ -162,293 +164,95 @@ defmodule ToweropsWeb.UserSettingsLive do end @impl true + # Mobile session management - delegated to SessionManager 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 + {: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 - 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 + {: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 - 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 + {:noreply, ApiTokenManager.create_api_token(socket, params)} 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} + {:noreply, ApiTokenManager.close_token_modal(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 + {: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 - 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 + {:noreply, SessionManager.revoke_session(socket, session_id)} end @impl true def handle_event("show_revoke_all_modal", _params, socket) do - {:noreply, assign(socket, :show_revoke_all_modal, true)} + {:noreply, SessionManager.show_revoke_all_modal(socket)} end @impl true def handle_event("cancel_revoke_all", _params, socket) do - {:noreply, assign(socket, :show_revoke_all_modal, false)} + {:noreply, SessionManager.cancel_revoke_all(socket)} 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} + {:noreply, SessionManager.confirm_revoke_all(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 - {:noreply, assign(socket, :show_add_device_modal, true)} + {:noreply, TotpManager.show_add_device_modal(socket)} end @impl true def handle_event("cancel_add_device", _params, socket) do - {:noreply, assign(socket, :show_add_device_modal, false)} + {:noreply, TotpManager.cancel_add_device(socket)} 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 + {:noreply, TotpManager.create_device(socket, name)} 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 + {:noreply, TotpManager.verify_new_device(socket, code)} 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} + {:noreply, TotpManager.close_device_qr_modal(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 + {:noreply, TotpManager.delete_device(socket, device_id)} end - # Security tab - Recovery Codes - + # Recovery codes - delegated to TotpManager @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 + {:noreply, TotpManager.regenerate_recovery_codes(socket)} 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} + {:noreply, TotpManager.close_recovery_codes_modal(socket)} end defp get_current_token_id(%{current_token: token}) when is_binary(token) do @@ -470,15 +274,12 @@ defmodule ToweropsWeb.UserSettingsLive do |> 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 + # 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 @@ -491,12 +292,6 @@ defmodule ToweropsWeb.UserSettingsLive do 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 @@ -530,1772 +325,14 @@ defmodule ToweropsWeb.UserSettingsLive do |> 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 + # Formatting helpers delegated to ToweropsWeb.UserSettingsLive.Helpers module + defdelegate current_session?(session, current_token_id), to: Helpers + defdelegate format_timestamp_in_timezone(datetime, timezone), 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 diff --git a/lib/towerops_web/live/user_settings_live.html.heex b/lib/towerops_web/live/user_settings_live.html.heex new file mode 100644 index 00000000..609f33e4 --- /dev/null +++ b/lib/towerops_web/live/user_settings_live.html.heex @@ -0,0 +1,1660 @@ + +
+

+ 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 %> + + + +
diff --git a/lib/towerops_web/live/user_settings_live/api_token_manager.ex b/lib/towerops_web/live/user_settings_live/api_token_manager.ex new file mode 100644 index 00000000..9e50d6fd --- /dev/null +++ b/lib/towerops_web/live/user_settings_live/api_token_manager.ex @@ -0,0 +1,81 @@ +defmodule ToweropsWeb.UserSettingsLive.ApiTokenManager do + @moduledoc """ + Handles API token management for user settings. + """ + + alias Towerops.ApiTokens + + @doc """ + Creates a new API token for the user. + """ + def create_api_token(socket, params) 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 ApiTokens.create_api_token(%{ + name: name, + organization_id: org_id, + user_id: user.id + }) do + {:ok, {_token, raw_token}} -> + socket + |> Phoenix.Component.assign(:show_add_token_modal, false) + |> Phoenix.Component.assign(:created_token, raw_token) + |> Phoenix.Component.assign(:show_token_modal, true) + |> assign_api_tokens() + + {:error, _changeset} -> + Phoenix.LiveView.put_flash(socket, :error, "Failed to create API token.") + end + else + Phoenix.LiveView.put_flash(socket, :error, "Name and organization are required.") + end + end + + @doc """ + Closes the token display modal. + """ + def close_token_modal(socket) do + socket + |> Phoenix.Component.assign(:show_token_modal, false) + |> Phoenix.Component.assign(:created_token, nil) + end + + @doc """ + Deletes an API token. + """ + def delete_api_token(socket, token_id) do + token = ApiTokens.get_api_token!(token_id) + user = socket.assigns.current_scope.user + + if token.user_id == user.id do + case ApiTokens.delete_api_token(token) do + {:ok, _} -> + socket + |> Phoenix.LiveView.put_flash(:info, "API token deleted successfully.") + |> assign_api_tokens() + + {:error, _} -> + Phoenix.LiveView.put_flash(socket, :error, "Failed to delete API token.") + end + else + Phoenix.LiveView.put_flash(socket, :error, "You can only delete your own tokens.") + end + end + + @doc """ + Assigns API tokens to socket. + """ + def assign_api_tokens(socket) do + user = socket.assigns.current_scope.user + Phoenix.Component.assign(socket, :api_tokens, ApiTokens.list_user_api_tokens(user.id)) + end +end diff --git a/lib/towerops_web/live/user_settings_live/helpers.ex b/lib/towerops_web/live/user_settings_live/helpers.ex new file mode 100644 index 00000000..7d59c083 --- /dev/null +++ b/lib/towerops_web/live/user_settings_live/helpers.ex @@ -0,0 +1,115 @@ +defmodule ToweropsWeb.UserSettingsLive.Helpers do + @moduledoc """ + Helper functions for formatting and displaying user settings data. + """ + + @doc """ + Checks if a session matches the current token ID. + """ + def current_session?(session, current_token_id) do + session.user_token_id == current_token_id + end + + @doc """ + Formats a UTC datetime in the user's timezone. + """ + def 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 + + @doc """ + Formats browser information from a session for display. + """ + def 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 + + @doc """ + Formats location information from GeoIP data. + """ + def 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 + + @doc """ + Formats a datetime as a relative time string (e.g., "5 minutes ago"). + """ + def 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 + + @doc """ + Returns icon name and display label for a login method. + """ + def 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 + + @doc """ + Generates pagination range with ellipsis for large page counts. + + Shows: [1] ... [4] [5] [6] ... [20] when on page 5 of 20 + """ + def 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 +end diff --git a/lib/towerops_web/live/user_settings_live/session_manager.ex b/lib/towerops_web/live/user_settings_live/session_manager.ex new file mode 100644 index 00000000..8f752ddd --- /dev/null +++ b/lib/towerops_web/live/user_settings_live/session_manager.ex @@ -0,0 +1,121 @@ +defmodule ToweropsWeb.UserSettingsLive.SessionManager do + @moduledoc """ + Handles browser session and mobile device session management. + """ + + alias Towerops.Accounts + alias Towerops.MobileSessions + + @doc """ + Revokes a mobile device session. + """ + def revoke_mobile_device(socket, session_id) do + case MobileSessions.revoke_session(session_id) do + {:ok, _} -> + socket + |> Phoenix.LiveView.put_flash(:info, "Mobile device removed successfully.") + |> assign_mobile_sessions() + + {:error, _} -> + Phoenix.LiveView.put_flash(socket, :error, "Failed to remove mobile device.") + end + end + + @doc """ + Toggles alert preferences for a mobile device. + """ + def toggle_device_alerts(socket, session_id, enabled) 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 + |> Phoenix.LiveView.put_flash(:info, message) + |> assign_mobile_sessions() + + {:error, _} -> + Phoenix.LiveView.put_flash(socket, :error, "Failed to update alert preferences.") + end + end + + @doc """ + Revokes a browser session. + """ + def revoke_session(socket, session_id) 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 + |> Phoenix.LiveView.put_flash(:info, "Session revoked successfully.") + |> assign_browser_sessions() + + {:error, :self_revoke} -> + Phoenix.LiveView.put_flash(socket, :error, "You cannot revoke your current session.") + + {:error, :not_found} -> + Phoenix.LiveView.put_flash(socket, :error, "Session not found.") + + {:error, _} -> + Phoenix.LiveView.put_flash(socket, :error, "Failed to revoke session.") + end + end + + @doc """ + Shows the revoke all sessions confirmation modal. + """ + def show_revoke_all_modal(socket) do + Phoenix.Component.assign(socket, :show_revoke_all_modal, true) + end + + @doc """ + Cancels revoking all sessions. + """ + def cancel_revoke_all(socket) do + Phoenix.Component.assign(socket, :show_revoke_all_modal, false) + end + + @doc """ + Confirms and revokes all other sessions except the current one. + """ + def confirm_revoke_all(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 + |> Phoenix.LiveView.put_flash(:info, "Revoked #{count} other session(s) successfully.") + |> Phoenix.Component.assign(:show_revoke_all_modal, false) + |> assign_browser_sessions() + end + + @doc """ + Assigns mobile sessions to socket. + """ + def assign_mobile_sessions(socket) do + user = socket.assigns.current_scope.user + Phoenix.Component.assign(socket, :mobile_sessions, MobileSessions.list_user_sessions(user.id)) + end + + @doc """ + Assigns browser sessions to socket. + """ + def assign_browser_sessions(socket) do + user = socket.assigns.current_scope.user + sessions = Accounts.list_active_browser_sessions(user.id) + Phoenix.Component.assign(socket, :browser_sessions, sessions) + end + + @doc """ + Gets the current token ID from socket assigns. + """ + def get_current_token_id(%{assigns: %{current_token: token}}) when is_binary(token) do + Accounts.get_user_token_id_by_value(token) + end + + def get_current_token_id(_), do: nil +end diff --git a/lib/towerops_web/live/user_settings_live/totp_manager.ex b/lib/towerops_web/live/user_settings_live/totp_manager.ex new file mode 100644 index 00000000..7878a470 --- /dev/null +++ b/lib/towerops_web/live/user_settings_live/totp_manager.ex @@ -0,0 +1,158 @@ +defmodule ToweropsWeb.UserSettingsLive.TotpManager do + @moduledoc """ + Handles TOTP (Time-based One-Time Password) device and recovery code management. + """ + + alias Towerops.Accounts + alias Towerops.Accounts.UserTotpDevice + + @doc """ + Shows the modal for adding a new TOTP device. + """ + def show_add_device_modal(socket) do + Phoenix.Component.assign(socket, :show_add_device_modal, true) + end + + @doc """ + Cancels adding a new TOTP device. + """ + def cancel_add_device(socket) do + Phoenix.Component.assign(socket, :show_add_device_modal, false) + end + + @doc """ + Creates a new TOTP device and displays the QR code. + """ + def create_device(socket, name) 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 + |> Phoenix.Component.assign(:show_add_device_modal, false) + |> Phoenix.Component.assign(:show_device_qr_modal, true) + |> Phoenix.Component.assign(:new_device_id, device.id) + |> Phoenix.Component.assign(:new_device_secret, secret) + |> Phoenix.Component.assign(:new_device_qr_code, qr_code) + + {:error, _changeset} -> + Phoenix.LiveView.put_flash(socket, :error, "Failed to create device.") + end + end + + @doc """ + Verifies a new TOTP device with a code from the authenticator app. + """ + def verify_new_device(socket, code) 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 + |> Phoenix.LiveView.put_flash(:info, "Device added successfully!") + |> Phoenix.Component.assign(:show_device_qr_modal, false) + |> Phoenix.Component.assign(:new_device_id, nil) + |> Phoenix.Component.assign(:new_device_secret, nil) + |> Phoenix.Component.assign(:new_device_qr_code, nil) + |> assign_totp_devices() + else + Phoenix.LiveView.put_flash(socket, :error, "Invalid code. Please try again.") + end + end + + @doc """ + Closes the QR code modal and deletes the unverified device. + """ + def close_device_qr_modal(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 + |> Phoenix.Component.assign(:show_device_qr_modal, false) + |> Phoenix.Component.assign(:new_device_id, nil) + |> Phoenix.Component.assign(:new_device_secret, nil) + |> Phoenix.Component.assign(:new_device_qr_code, nil) + |> assign_totp_devices() + end + + @doc """ + Deletes a TOTP device. + """ + def delete_device(socket, device_id) do + user = socket.assigns.current_scope.user + + case Accounts.delete_totp_device(device_id, user.id) do + {:ok, _} -> + socket + |> Phoenix.LiveView.put_flash(:info, "Device removed successfully.") + |> assign_totp_devices() + + {:error, :last_device} -> + Phoenix.LiveView.put_flash( + socket, + :error, + "Cannot remove last device. You must have at least one." + ) + + {:error, _} -> + Phoenix.LiveView.put_flash(socket, :error, "Failed to remove device.") + end + end + + @doc """ + Regenerates recovery codes for the user. + """ + def regenerate_recovery_codes(socket) do + user = socket.assigns.current_scope.user + + case Accounts.generate_recovery_codes(user.id) do + {:ok, codes} -> + socket + |> Phoenix.Component.assign(:show_recovery_codes_modal, true) + |> Phoenix.Component.assign(:generated_recovery_codes, codes) + |> assign_recovery_codes_count() + + {:error, _} -> + Phoenix.LiveView.put_flash(socket, :error, "Failed to generate recovery codes.") + end + end + + @doc """ + Closes the recovery codes modal. + """ + def close_recovery_codes_modal(socket) do + socket + |> Phoenix.Component.assign(:show_recovery_codes_modal, false) + |> Phoenix.Component.assign(:generated_recovery_codes, nil) + end + + @doc """ + Assigns TOTP devices to socket. + """ + def assign_totp_devices(socket) do + user = socket.assigns.current_scope.user + devices = Accounts.list_user_totp_devices(user.id) + Phoenix.Component.assign(socket, :totp_devices, devices) + end + + @doc """ + Assigns recovery codes count to socket. + """ + def assign_recovery_codes_count(socket) do + user = socket.assigns.current_scope.user + count = Accounts.count_unused_recovery_codes(user.id) + Phoenix.Component.assign(socket, :unused_recovery_codes_count, count) + end +end