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)
This commit is contained in:
Graham McIntire 2026-03-13 17:28:36 -05:00
parent 326932b68e
commit 7251930404
No known key found for this signature in database
7 changed files with 487 additions and 24 deletions

View file

@ -0,0 +1,97 @@
defmodule ToweropsWeb.MobileChannel do
@moduledoc """
Phoenix channel for real-time mobile app communication.
Supports two topic patterns:
- `mobile:org:<org_id>` org-level alerts and device status events
- `mobile:device:<device_id>` 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

View file

@ -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

View file

@ -297,6 +297,17 @@ defmodule ToweropsWeb.Layouts do
>
{t("User Settings")}
</.link>
<.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")}
>
<span class="flex items-center gap-2">
<.icon name="hero-device-phone-mobile" class="h-4 w-4" />
{t("Link Mobile App")}
</span>
</.link>
<.link
role="menuitem"
navigate={~p"/users/my-data"}

View file

@ -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),

View file

@ -111,22 +111,15 @@ defmodule ToweropsWeb.MobileQRLive do
<Layouts.app flash={@flash} timezone={@timezone}>
<div class="mx-auto max-w-4xl">
<.header>
Mobile App Login
<:subtitle>Scan this QR code with your Towerops mobile app to log in</:subtitle>
Link Mobile App
<:subtitle>
Scan this QR code with the Towerops app on your phone to link it to your account
</:subtitle>
</.header>
<div :if={!@completed} class="mt-8">
<div class="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800/50 dark:ring-white/10">
<div class="flex flex-col items-center space-y-6">
<div class="text-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Scan with Mobile App
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Open the Towerops mobile app and scan this QR code to log in
</p>
</div>
<div class="rounded-lg bg-white p-4">
<img
src={qr_code_url(@qr_token.token)}
@ -139,34 +132,63 @@ defmodule ToweropsWeb.MobileQRLive do
<p class="text-xs text-gray-500 dark:text-gray-400">
This QR code expires in 5 minutes
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Waiting for mobile app to scan...
</p>
</div>
<div class="flex items-center space-x-2">
<div class="h-2 w-2 animate-pulse rounded-full bg-blue-500"></div>
<span class="text-sm text-gray-600 dark:text-gray-400">Checking...</span>
<span class="text-sm text-gray-600 dark:text-gray-400">
Waiting for mobile app to scan...
</span>
</div>
</div>
</div>
<div class="mt-6 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="mt-6 rounded-lg bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800/50 dark:ring-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
How to link your phone
</h3>
<ol class="mt-4 space-y-3">
<li class="flex items-start gap-3">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">
1
</span>
<span class="text-sm text-gray-600 dark:text-gray-400">
Open the <span class="font-medium text-gray-900 dark:text-white">Towerops</span>
app on your iPhone
</span>
</li>
<li class="flex items-start gap-3">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">
2
</span>
<span class="text-sm text-gray-600 dark:text-gray-400">
Tap <span class="font-medium text-gray-900 dark:text-white">"Scan QR Code"</span>
on the login screen
</span>
</li>
<li class="flex items-start gap-3">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">
3
</span>
<span class="text-sm text-gray-600 dark:text-gray-400">
Point your camera at the QR code above
</span>
</li>
</ol>
</div>
<div class="mt-4 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="flex">
<div class="flex-shrink-0">
<.icon name="hero-information-circle" class="h-5 w-5 text-blue-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300">
Don't have the mobile app yet?
Don't have the app yet?
</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-400">
<p>Download the Towerops mobile app from:</p>
<ul class="mt-2 list-inside list-disc space-y-1">
<li>App Store (iOS)</li>
<li>Google Play (Android - coming soon)</li>
</ul>
</div>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-400">
Download Towerops from the App Store on your iPhone.
</p>
</div>
</div>
</div>

View file

@ -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

View file

@ -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