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:
parent
326932b68e
commit
7251930404
7 changed files with 487 additions and 24 deletions
97
lib/towerops_web/channels/mobile_channel.ex
Normal file
97
lib/towerops_web/channels/mobile_channel.ex
Normal 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
|
||||
48
lib/towerops_web/channels/mobile_socket.ex
Normal file
48
lib/towerops_web/channels/mobile_socket.ex
Normal 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
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
209
test/towerops_web/channels/mobile_channel_test.exs
Normal file
209
test/towerops_web/channels/mobile_channel_test.exs
Normal 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
|
||||
72
test/towerops_web/channels/mobile_socket_test.exs
Normal file
72
test/towerops_web/channels/mobile_socket_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue