bug fix
This commit is contained in:
parent
1790db8d5a
commit
1414f087fc
6 changed files with 2175 additions and 2003 deletions
File diff suppressed because it is too large
Load diff
1660
lib/towerops_web/live/user_settings_live.html.heex
Normal file
1660
lib/towerops_web/live/user_settings_live.html.heex
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
115
lib/towerops_web/live/user_settings_live/helpers.ex
Normal file
115
lib/towerops_web/live/user_settings_live/helpers.ex
Normal file
|
|
@ -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
|
||||
121
lib/towerops_web/live/user_settings_live/session_manager.ex
Normal file
121
lib/towerops_web/live/user_settings_live/session_manager.ex
Normal file
|
|
@ -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
|
||||
158
lib/towerops_web/live/user_settings_live/totp_manager.ex
Normal file
158
lib/towerops_web/live/user_settings_live/totp_manager.ex
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue