From 72519304042203983c09acb6457a23819b27177b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Mar 2026 17:28:36 -0500 Subject: [PATCH] add mobile app websocket channel and improve QR linking UX - add MobileSocket for token-authenticated mobile WebSocket connections - add MobileChannel with org-level and device-level real-time events - add /mobile/socket endpoint for iOS app connections - add "Link Mobile App" to user dropdown menu for discoverability - improve QR code page with numbered steps and clearer instructions - add socket/channel tests (17 total) --- lib/towerops_web/channels/mobile_channel.ex | 97 ++++++++ lib/towerops_web/channels/mobile_socket.ex | 48 ++++ lib/towerops_web/components/layouts.ex | 11 + lib/towerops_web/endpoint.ex | 4 + lib/towerops_web/live/mobile_qr_live.ex | 70 ++++-- .../channels/mobile_channel_test.exs | 209 ++++++++++++++++++ .../channels/mobile_socket_test.exs | 72 ++++++ 7 files changed, 487 insertions(+), 24 deletions(-) create mode 100644 lib/towerops_web/channels/mobile_channel.ex create mode 100644 lib/towerops_web/channels/mobile_socket.ex create mode 100644 test/towerops_web/channels/mobile_channel_test.exs create mode 100644 test/towerops_web/channels/mobile_socket_test.exs diff --git a/lib/towerops_web/channels/mobile_channel.ex b/lib/towerops_web/channels/mobile_channel.ex new file mode 100644 index 00000000..45b2312a --- /dev/null +++ b/lib/towerops_web/channels/mobile_channel.ex @@ -0,0 +1,97 @@ +defmodule ToweropsWeb.MobileChannel do + @moduledoc """ + Phoenix channel for real-time mobile app communication. + + Supports two topic patterns: + - `mobile:org:` — org-level alerts and device status events + - `mobile:device:` — device-level sensor and interface events + """ + + use ToweropsWeb, :channel + + alias Towerops.Devices + alias Towerops.Organizations + + @impl true + def join("mobile:org:" <> org_id, _params, socket) do + user_id = socket.assigns.user_id + + if Organizations.user_has_access?(user_id, org_id) do + Phoenix.PubSub.subscribe(Towerops.PubSub, "organization:#{org_id}:alerts") + Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{org_id}") + + {:ok, socket} + else + {:error, %{reason: "unauthorized"}} + end + end + + def join("mobile:device:" <> device_id, _params, socket) do + user_id = socket.assigns.user_id + + case Devices.get_device(device_id) do + nil -> + {:error, %{reason: "not_found"}} + + device -> + if Organizations.user_has_access?(user_id, device.organization_id) do + Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}") + {:ok, socket} + else + {:error, %{reason: "unauthorized"}} + end + end + end + + # Org-level: alert changed + @impl true + def handle_info({:alert_changed, org_id}, socket) do + push(socket, "alert:changed", %{organization_id: org_id}) + {:noreply, socket} + end + + # Org-level: device events + def handle_info({event, org_id}, socket) + when event in [:device_created, :device_updated, :device_status_changed, :device_deleted] do + push(socket, "device:changed", %{ + event: to_string(event), + organization_id: org_id + }) + + {:noreply, socket} + end + + # Device-level: sensor and interface events from SensorChangeDetector/DevicePollerWorker + def handle_info({:device_event, event}, socket) when is_map(event) do + push(socket, "device:event", %{ + device_id: event.device_id, + event_type: event.event_type, + severity: event.severity, + message: event.message, + metadata: event.metadata, + occurred_at: format_datetime(event.occurred_at) + }) + + {:noreply, socket} + end + + # Device-level: bulk data updates + def handle_info({update_type, device_id}, socket) + when update_type in [:sensors_updated, :state_sensors_updated, :interfaces_updated, :neighbors_updated] and + is_binary(device_id) do + push(socket, "device:updated", %{ + device_id: device_id, + update_type: to_string(update_type) + }) + + {:noreply, socket} + end + + # Catch-all for unhandled PubSub messages + def handle_info(_msg, socket) do + {:noreply, socket} + end + + defp format_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt) + defp format_datetime(other), do: to_string(other) +end diff --git a/lib/towerops_web/channels/mobile_socket.ex b/lib/towerops_web/channels/mobile_socket.ex new file mode 100644 index 00000000..451bd2f5 --- /dev/null +++ b/lib/towerops_web/channels/mobile_socket.ex @@ -0,0 +1,48 @@ +defmodule ToweropsWeb.MobileSocket do + @moduledoc """ + WebSocket endpoint for mobile app communication. + + Mobile clients connect to: ws://server/mobile/socket/websocket + Authentication happens at connect time using a bearer token from + the mobile session system (QR code login flow). + + The token is passed as a `"token"` parameter during the WebSocket + handshake. It is validated against the `mobile_sessions` table, + and expired sessions are rejected. + """ + + use Phoenix.Socket + + alias Towerops.MobileSessions + + channel "mobile:org:*", ToweropsWeb.MobileChannel + channel "mobile:device:*", ToweropsWeb.MobileChannel + + @impl true + @spec connect(map(), Phoenix.Socket.t(), map()) :: {:ok, Phoenix.Socket.t()} | :error + def connect(%{"token" => token}, socket, _connect_info) when is_binary(token) do + case MobileSessions.get_session_by_token(token) do + nil -> + :error + + session -> + session = Towerops.Repo.preload(session, :user) + Task.start(fn -> MobileSessions.touch_session(session) end) + + socket = + socket + |> assign(:user_id, session.user_id) + |> assign(:mobile_session_id, session.id) + + {:ok, socket} + end + end + + def connect(_params, _socket, _connect_info), do: :error + + @impl true + @spec id(Phoenix.Socket.t()) :: String.t() + def id(socket) do + "mobile_socket:#{socket.assigns.user_id}" + end +end diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 8f89c558..be01b626 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -297,6 +297,17 @@ defmodule ToweropsWeb.Layouts do > {t("User Settings")} + <.link + role="menuitem" + navigate={~p"/mobile/qr-login"} + class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5" + phx-click={JS.hide(to: "#org-menu")} + > + + <.icon name="hero-device-phone-mobile" class="h-4 w-4" /> + {t("Link Mobile App")} + + <.link role="menuitem" navigate={~p"/users/my-data"} diff --git a/lib/towerops_web/endpoint.ex b/lib/towerops_web/endpoint.ex index 36c3530d..856e3745 100644 --- a/lib/towerops_web/endpoint.ex +++ b/lib/towerops_web/endpoint.ex @@ -25,6 +25,10 @@ defmodule ToweropsWeb.Endpoint do websocket: true, longpoll: false + socket "/mobile/socket", ToweropsWeb.MobileSocket, + websocket: [timeout: 60_000], + longpoll: false + # Serve at "/" the static files from "priv/static" directory. # # When code reloading is disabled (e.g., in production), diff --git a/lib/towerops_web/live/mobile_qr_live.ex b/lib/towerops_web/live/mobile_qr_live.ex index 4c5ea034..4637b63f 100644 --- a/lib/towerops_web/live/mobile_qr_live.ex +++ b/lib/towerops_web/live/mobile_qr_live.ex @@ -111,22 +111,15 @@ defmodule ToweropsWeb.MobileQRLive do
<.header> - Mobile App Login - <:subtitle>Scan this QR code with your Towerops mobile app to log in + Link Mobile App + <:subtitle> + Scan this QR code with the Towerops app on your phone to link it to your account +
-
-

- Scan with Mobile App -

-

- Open the Towerops mobile app and scan this QR code to log in -

-
-
This QR code expires in 5 minutes

-

- Waiting for mobile app to scan... -

- Checking... + + Waiting for mobile app to scan... +
-
+
+

+ How to link your phone +

+
    +
  1. + + 1 + + + Open the Towerops + app on your iPhone + +
  2. +
  3. + + 2 + + + Tap "Scan QR Code" + on the login screen + +
  4. +
  5. + + 3 + + + Point your camera at the QR code above + +
  6. +
+
+ +
<.icon name="hero-information-circle" class="h-5 w-5 text-blue-400" />

- Don't have the mobile app yet? + Don't have the app yet?

-
-

Download the Towerops mobile app from:

-
    -
  • App Store (iOS)
  • -
  • Google Play (Android - coming soon)
  • -
-
+

+ Download Towerops from the App Store on your iPhone. +

diff --git a/test/towerops_web/channels/mobile_channel_test.exs b/test/towerops_web/channels/mobile_channel_test.exs new file mode 100644 index 00000000..07b38cb5 --- /dev/null +++ b/test/towerops_web/channels/mobile_channel_test.exs @@ -0,0 +1,209 @@ +defmodule ToweropsWeb.MobileChannelTest do + use Towerops.DataCase, async: false + + import Phoenix.ChannelTest + + alias Towerops.MobileSessions + alias ToweropsWeb.MobileSocket + + @endpoint ToweropsWeb.Endpoint + + setup do + user = Towerops.AccountsFixtures.user_fixture() + organization = Towerops.OrganizationsFixtures.organization_fixture(user.id) + + device = + Towerops.DevicesFixtures.device_fixture(%{ + organization_id: organization.id, + snmp_version: "2c", + snmp_community: "public" + }) + + {:ok, qr_token} = MobileSessions.create_qr_login_token(user.id) + + {:ok, session} = + MobileSessions.complete_qr_login(qr_token.token, %{ + device_name: "Test iPhone", + device_os: "iOS 18.0", + app_version: "1.0.0" + }) + + {:ok, socket} = connect(MobileSocket, %{"token" => session.raw_token}) + + %{socket: socket, user: user, organization: organization, device: device} + end + + # --- Org-level join --- + + describe "join mobile:org:* topic" do + test "joins successfully with valid org access", %{socket: socket, organization: org} do + assert {:ok, _reply, _socket} = + subscribe_and_join(socket, "mobile:org:#{org.id}", %{}) + end + + test "rejects join for unauthorized org", %{socket: socket} do + fake_org_id = Ecto.UUID.generate() + + assert {:error, %{reason: "unauthorized"}} = + subscribe_and_join(socket, "mobile:org:#{fake_org_id}", %{}) + end + end + + # --- Org-level alert events --- + + describe "org-level alert events" do + test "pushes alert:changed when alert broadcast received", %{ + socket: socket, + organization: org + } do + {:ok, _reply, _socket} = subscribe_and_join(socket, "mobile:org:#{org.id}", %{}) + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "organization:#{org.id}:alerts", + {:alert_changed, org.id} + ) + + assert_push "alert:changed", %{organization_id: _} + end + end + + # --- Org-level device events --- + + describe "org-level device events" do + test "pushes device:changed on device_status_changed", %{ + socket: socket, + organization: org + } do + {:ok, _reply, _socket} = subscribe_and_join(socket, "mobile:org:#{org.id}", %{}) + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "devices:org:#{org.id}", + {:device_status_changed, org.id} + ) + + assert_push "device:changed", %{event: "device_status_changed"} + end + + test "pushes device:changed on device_created", %{socket: socket, organization: org} do + {:ok, _reply, _socket} = subscribe_and_join(socket, "mobile:org:#{org.id}", %{}) + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "devices:org:#{org.id}", + {:device_created, org.id} + ) + + assert_push "device:changed", %{event: "device_created"} + end + + test "pushes device:changed on device_updated", %{socket: socket, organization: org} do + {:ok, _reply, _socket} = subscribe_and_join(socket, "mobile:org:#{org.id}", %{}) + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "devices:org:#{org.id}", + {:device_updated, org.id} + ) + + assert_push "device:changed", %{event: "device_updated"} + end + + test "pushes device:changed on device_deleted", %{socket: socket, organization: org} do + {:ok, _reply, _socket} = subscribe_and_join(socket, "mobile:org:#{org.id}", %{}) + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "devices:org:#{org.id}", + {:device_deleted, org.id} + ) + + assert_push "device:changed", %{event: "device_deleted"} + end + end + + # --- Device-level join --- + + describe "join mobile:device:* topic" do + test "joins successfully for device in user's org", %{socket: socket, device: device} do + assert {:ok, _reply, _socket} = + subscribe_and_join(socket, "mobile:device:#{device.id}", %{}) + end + + test "rejects join for non-existent device", %{socket: socket} do + assert {:error, %{reason: "not_found"}} = + subscribe_and_join(socket, "mobile:device:#{Ecto.UUID.generate()}", %{}) + end + end + + # --- Device-level sensor events --- + + describe "device-level sensor events" do + test "pushes device:event for sensor threshold change", %{ + socket: socket, + device: device + } do + {:ok, _reply, _socket} = + subscribe_and_join(socket, "mobile:device:#{device.id}", %{}) + + event = %{ + device_id: device.id, + event_type: "sensor_threshold_critical", + severity: "critical", + message: "CPU temperature exceeded 90C", + metadata: %{ + sensor_name: "CPU Temp", + sensor_type: "temperature", + current_value: 95.0, + threshold_value: 90.0 + }, + occurred_at: DateTime.utc_now() + } + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{device.id}", + {:device_event, event} + ) + + assert_push "device:event", payload + assert payload.event_type == "sensor_threshold_critical" + assert payload.severity == "critical" + assert payload.device_id == device.id + end + end + + # --- Device-level bulk updates --- + + describe "device-level bulk updates" do + test "pushes device:updated for sensors_updated", %{socket: socket, device: device} do + {:ok, _reply, _socket} = + subscribe_and_join(socket, "mobile:device:#{device.id}", %{}) + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{device.id}", + {:sensors_updated, device.id} + ) + + assert_push "device:updated", %{ + device_id: _, + update_type: "sensors_updated" + } + end + + test "pushes device:updated for interfaces_updated", %{socket: socket, device: device} do + {:ok, _reply, _socket} = + subscribe_and_join(socket, "mobile:device:#{device.id}", %{}) + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{device.id}", + {:interfaces_updated, device.id} + ) + + assert_push "device:updated", %{update_type: "interfaces_updated"} + end + end +end diff --git a/test/towerops_web/channels/mobile_socket_test.exs b/test/towerops_web/channels/mobile_socket_test.exs new file mode 100644 index 00000000..8c909236 --- /dev/null +++ b/test/towerops_web/channels/mobile_socket_test.exs @@ -0,0 +1,72 @@ +defmodule ToweropsWeb.MobileSocketTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.MobileSessions + alias Towerops.Repo + alias ToweropsWeb.MobileSocket + + setup do + user = user_fixture() + _org = organization_fixture(user.id) + + {:ok, qr_token} = MobileSessions.create_qr_login_token(user.id) + + {:ok, session} = + MobileSessions.complete_qr_login(qr_token.token, %{ + device_name: "Test iPhone", + device_os: "iOS 17.0", + app_version: "1.0.0" + }) + + %{user: user, session: session} + end + + describe "connect/3" do + test "authenticates with valid token", %{session: session, user: user} do + assert {:ok, socket} = + MobileSocket.connect(%{"token" => session.raw_token}, socket(), %{}) + + assert socket.assigns.user_id == user.id + assert socket.assigns.mobile_session_id == session.id + end + + test "returns error with no params" do + assert :error = MobileSocket.connect(%{}, socket(), %{}) + end + + test "returns error with invalid token" do + assert :error = MobileSocket.connect(%{"token" => "bogus-token"}, socket(), %{}) + end + + test "returns error with expired session", %{session: session} do + # Expire the session by setting expires_at to the past + past = DateTime.utc_now() |> DateTime.add(-1, :day) |> DateTime.truncate(:second) + + session + |> Ecto.Changeset.change(expires_at: past) + |> Repo.update!() + + assert :error = MobileSocket.connect(%{"token" => session.raw_token}, socket(), %{}) + end + end + + describe "id/1" do + test "returns correct socket id format", %{session: session, user: user} do + {:ok, socket} = + MobileSocket.connect(%{"token" => session.raw_token}, socket(), %{}) + + assert MobileSocket.id(socket) == "mobile_socket:#{user.id}" + end + end + + defp socket do + %Phoenix.Socket{ + transport: :websocket, + serializer: Phoenix.Socket.V2.JSONSerializer, + endpoint: ToweropsWeb.Endpoint + } + end +end