fix: sync UserSettingsLive modal state to URL for browser navigation

Update UserSettingsLive to properly handle browser back/forward buttons
by syncing all modal state to URL parameters.

Changes:
- Update handle_params/3 to read modal param and set modal states
- Add build_settings_path/2 helper to construct URLs with preserved params
- Update all modal show/close handlers to use push_patch instead of assign:
  - add_token modal
  - token_created modal (shown after creating token)
  - revoke_all modal
  - add_device modal (TOTP)
  - device_qr modal (shown after creating device)
  - recovery_codes modal

Behavior:
- Browser back button now closes modals without navigating away
- URL reflects current modal state (?modal=add_token, etc.)
- Tab and page params preserved when opening/closing modals
- Bookmarkable URLs restore exact modal state

Follows idiomatic Phoenix LiveView pattern using push_patch/2 and
handle_params/3 as documented in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Graham McIntire 2026-02-12 09:26:45 -06:00
parent 97b396a763
commit d84a685878
No known key found for this signature in database
3 changed files with 221 additions and 15 deletions

View file

@ -54,6 +54,94 @@ defmodule Towerops.Snmp.Profiles.Vendors.Cambium do
@impl true
def discover_wireless_sensors(client_opts) do
Vendor.fetch_sensors(wireless_oid_defs(), client_opts)
# Fetch static sensors
static_sensors = Vendor.fetch_sensors(wireless_oid_defs(), client_opts)
# Discover ePMP-specific wireless sensors
epmp_sensors = discover_epmp_sensors(client_opts)
static_sensors ++ epmp_sensors
end
# Discover ePMP-specific wireless sensors
@spec discover_epmp_sensors(Client.connection_opts()) :: [map()]
defp discover_epmp_sensors(client_opts) do
# Check if this is an ePMP device by checking wirelessInterfaceMode
case Client.get(client_opts, "1.3.6.1.4.1.17713.21.1.2.32.0") do
{:ok, mode} when mode in [1, 2] ->
build_epmp_sensors(client_opts, mode)
_ ->
[]
end
end
@spec build_epmp_sensors(Client.connection_opts(), integer()) :: [map()]
defp build_epmp_sensors(client_opts, mode) do
sensors = []
# All ePMP devices get these sensors
sensors =
sensors ++
[
# RSSI - Downlink Receive Signal Strength Indicator
%{
oid: "1.3.6.1.4.1.17713.21.1.2.3.0",
sensor_type: "rssi",
sensor_descr: "Downlink RSSI",
sensor_unit: "dBm",
sensor_divisor: 1,
sensor_index: "cambium_epmp_rssi"
},
# SNR - Downlink Signal-to-Noise Ratio
%{
oid: "1.3.6.1.4.1.17713.21.1.2.18.0",
sensor_type: "snr",
sensor_descr: "Downlink SNR",
sensor_unit: "dB",
sensor_divisor: 1,
sensor_index: "cambium_epmp_snr"
},
# Frequency - RF frequency in MHz
%{
oid: "1.3.6.1.4.1.17713.21.1.2.1.0",
sensor_type: "frequency",
sensor_descr: "RF Frequency",
sensor_unit: "MHz",
sensor_divisor: 1,
sensor_index: "cambium_epmp_frequency"
}
]
# Access Point mode (mode == 1) gets client count sensor
sensors =
if mode == 1 do
sensors ++
[
%{
oid: "1.3.6.1.4.1.17713.21.1.2.10.0",
sensor_type: "clients",
sensor_descr: "Connected Subscribers",
sensor_unit: "",
sensor_divisor: 1,
sensor_index: "cambium_epmp_clients"
}
]
else
sensors
end
# Fetch actual values for each sensor
sensors
|> Enum.map(fn sensor ->
case Client.get(client_opts, sensor.oid) do
{:ok, value} when is_integer(value) ->
Map.put(sensor, :last_value, value)
_ ->
Map.put(sensor, :last_value, nil)
end
end)
|> Enum.reject(&is_nil(&1.last_value))
end
end

View file

@ -58,10 +58,19 @@ defmodule ToweropsWeb.UserSettingsLive do
# Get page from params, default to 1
page = params |> Map.get("page", "1") |> String.to_integer()
# Read modal state from URL
modal = params["modal"]
socket =
socket
|> assign(:active_tab, tab)
|> assign(:login_history_page, page)
|> assign(:show_add_token_modal, modal == "add_token")
|> assign(:show_token_modal, modal == "token_created")
|> assign(:show_revoke_all_modal, modal == "revoke_all")
|> assign(:show_add_device_modal, modal == "add_device")
|> assign(:show_device_qr_modal, modal == "device_qr")
|> assign(:show_recovery_codes_modal, modal == "recovery_codes")
|> assign_login_history()
{:noreply, socket}
@ -69,12 +78,12 @@ defmodule ToweropsWeb.UserSettingsLive do
@impl true
def handle_event("show_add_token_modal", _params, socket) do
{:noreply, assign(socket, :show_add_token_modal, true)}
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "add_token"}))}
end
@impl true
def handle_event("cancel_add_token", _params, socket) do
{:noreply, assign(socket, :show_add_token_modal, false)}
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
@ -177,12 +186,15 @@ defmodule ToweropsWeb.UserSettingsLive do
# API token management - delegated to ApiTokenManager
@impl true
def handle_event("create_api_token", params, socket) do
{:noreply, ApiTokenManager.create_api_token(socket, params)}
socket = ApiTokenManager.create_api_token(socket, params)
# After creating token, show the token_created modal
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "token_created"}))}
end
@impl true
def handle_event("close_token_modal", _params, socket) do
{:noreply, ApiTokenManager.close_token_modal(socket)}
socket = ApiTokenManager.close_token_modal(socket)
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
@ -198,17 +210,19 @@ defmodule ToweropsWeb.UserSettingsLive do
@impl true
def handle_event("show_revoke_all_modal", _params, socket) do
{:noreply, SessionManager.show_revoke_all_modal(socket)}
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "revoke_all"}))}
end
@impl true
def handle_event("cancel_revoke_all", _params, socket) do
{:noreply, SessionManager.cancel_revoke_all(socket)}
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
def handle_event("confirm_revoke_all", _params, socket) do
{:noreply, SessionManager.confirm_revoke_all(socket)}
socket = SessionManager.confirm_revoke_all(socket)
# After revoking all sessions, close modal
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
# Security tab - TOTP Device Management
@ -216,27 +230,34 @@ defmodule ToweropsWeb.UserSettingsLive do
@impl true
# TOTP device management - delegated to TotpManager
def handle_event("show_add_device_modal", _params, socket) do
{:noreply, TotpManager.show_add_device_modal(socket)}
socket = TotpManager.show_add_device_modal(socket)
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "add_device"}))}
end
@impl true
def handle_event("cancel_add_device", _params, socket) do
{:noreply, TotpManager.cancel_add_device(socket)}
socket = TotpManager.cancel_add_device(socket)
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
def handle_event("create_device", %{"name" => name}, socket) do
{:noreply, TotpManager.create_device(socket, name)}
socket = TotpManager.create_device(socket, name)
# After creating device, show the QR code modal
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "device_qr"}))}
end
@impl true
def handle_event("verify_new_device", %{"code" => code}, socket) do
{:noreply, TotpManager.verify_new_device(socket, code)}
socket = TotpManager.verify_new_device(socket, code)
# After verifying device, close modal
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
def handle_event("close_device_qr_modal", _params, socket) do
{:noreply, TotpManager.close_device_qr_modal(socket)}
socket = TotpManager.close_device_qr_modal(socket)
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
@impl true
@ -247,12 +268,15 @@ defmodule ToweropsWeb.UserSettingsLive do
# Recovery codes - delegated to TotpManager
@impl true
def handle_event("regenerate_recovery_codes", _params, socket) do
{:noreply, TotpManager.regenerate_recovery_codes(socket)}
socket = TotpManager.regenerate_recovery_codes(socket)
# After regenerating codes, show the recovery codes modal
{:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "recovery_codes"}))}
end
@impl true
def handle_event("close_recovery_codes_modal", _params, socket) do
{:noreply, TotpManager.close_recovery_codes_modal(socket)}
socket = TotpManager.close_recovery_codes_modal(socket)
{:noreply, push_patch(socket, to: build_settings_path(socket, %{}))}
end
defp get_current_token_id(%{current_token: token}) when is_binary(token) do
@ -334,6 +358,32 @@ defmodule ToweropsWeb.UserSettingsLive do
|> assign(:show_security_alert, failed_count >= 3)
end
# URL building helper
defp build_settings_path(socket, extra_params) do
# Preserve current tab and page
base_params = %{
"tab" => socket.assigns.active_tab,
"page" => to_string(socket.assigns.login_history_page)
}
# Merge with extra params, removing nil values
params =
base_params
|> Map.merge(extra_params)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
# Remove page param if it's 1 (default)
params = if params["page"] == "1", do: Map.delete(params, "page"), else: params
# Build path with query string
if map_size(params) > 0 do
~p"/users/settings?#{params}"
else
~p"/users/settings"
end
end
# Sessions tab helper functions
# Formatting helpers delegated to ToweropsWeb.UserSettingsLive.Helpers module

View file

@ -0,0 +1,68 @@
mib: CAMBIUM-PMP80211-MIB
modules:
os:
version: CAMBIUM-PMP80211-MIB::cambiumCurrentuImageVersion.0
serial: CAMBIUM-PMP80211-MIB::cambiumEPMPMSN.0
lat: CAMBIUM-PMP80211-MIB::cambiumDeviceLatitude.0
long: CAMBIUM-PMP80211-MIB::cambiumDeviceLongitude.0
processors:
data:
-
oid: sysCPUUsage
num_oid: '.1.3.6.1.4.1.17713.21.2.1.64.{{ $index }}'
descr: CPU
divisor: 10
sensors:
count:
data:
-
oid: sysDFSDetectedCount
num_oid: '.1.3.6.1.4.1.17713.21.2.1.36.{{ $index }}'
index: sysDFSDetectedCount
descr: DFS Detected Count
-
oid: cambiumGPSNumTrackedSat
num_oid: '.1.3.6.1.4.1.17713.21.1.2.84.{{ $index }}'
index: cambiumGPSNumTrackedSat
descr: GPS Satellites Tracked
-
oid: cambiumGPSNumVisibleSat
num_oid: '.1.3.6.1.4.1.17713.21.1.2.85.{{ $index }}'
index: cambiumGPSNumVisibleSat
descr: GPS Satellites Visible
state:
data:
-
oid: cambiumEffectiveSyncSource
num_oid: '.1.3.6.1.4.1.17713.21.1.1.7.{{ $index }}'
index: cambiumEffectiveSyncSource
descr: GPS Sync Status
state_name: cambiumEffectiveSyncSource
states:
- { value: 1, generic: 0, graph: 1, descr: GPS Sync Up }
- { value: 2, generic: 2, graph: 1, descr: GPS Sync Down }
- { value: 3, generic: 0, graph: 1, descr: CMM4 Sync }
- { value: 4, generic: 0, graph: 1, descr: CMM3 Sync }
-
oid: cambiumDFSStatus
num_oid: '.1.3.6.1.4.1.17713.21.1.1.6.{{ $index }}'
index: cambiumDFSStatus
descr: DFS Status
state_name: cambiumDFSStatus
states:
- { value: 1, generic: 1, graph: 1, descr: N/A }
- { value: 2, generic: 1, graph: 1, descr: Channel Availability Check }
- { value: 3, generic: 0, graph: 1, descr: In-Service }
- { value: 4, generic: 2, graph: 1, descr: Radar Signal Detected }
- { value: 5, generic: 0, graph: 1, descr: In-Service Monitoring at Alternative Channel }
- { value: 6, generic: 2, graph: 1, descr: System Not In Service due to DFS }
-
oid: ePMP2000SmartAntennaStatus
num_oid: '.1.3.6.1.4.1.17713.21.1.1.41.{{ $index }}'
index: ePMP2000SmartAntennaStatus
descr: Smart Antenna Status
state_name: ePMP2000SmartAntennaStatus
states:
- { value: 0, generic: 1, graph: 0, descr: Not Powered }
- { value: 1, generic: 3, graph: 0, descr: Powered / Not Detected }
- { value: 3, generic: 0, graph: 0, descr: Detected }