diff --git a/lib/microwaveprop/accounts.ex b/lib/microwaveprop/accounts.ex
index 9ca0ea57..0dd1d222 100644
--- a/lib/microwaveprop/accounts.ex
+++ b/lib/microwaveprop/accounts.ex
@@ -100,6 +100,15 @@ defmodule Microwaveprop.Accounts do
Repo.all(from u in User, order_by: [asc: u.callsign], limit: 100)
end
+ @doc """
+ Returns lightweight id/callsign pairs for use in select dropdowns.
+ Used by admin forms that need to pick a user.
+ """
+ @spec list_users_select() :: [{String.t(), Ecto.UUID.t()}]
+ def list_users_select do
+ Repo.all(from u in User, order_by: [asc: u.callsign], select: {u.callsign, u.id})
+ end
+
@doc "Updates admin-managed user fields (callsign, name, email, is_admin)."
@spec admin_update_user(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def admin_update_user(%User{} = user, attrs) do
diff --git a/lib/microwaveprop/beacon_monitors.ex b/lib/microwaveprop/beacon_monitors.ex
index 05aab6f5..e3bc4b72 100644
--- a/lib/microwaveprop/beacon_monitors.ex
+++ b/lib/microwaveprop/beacon_monitors.ex
@@ -37,7 +37,7 @@ defmodule Microwaveprop.BeaconMonitors do
"""
@spec get_monitor!(Ecto.UUID.t()) :: BeaconMonitor.t()
def get_monitor!(monitor_id) do
- BeaconMonitor |> Repo.get!(monitor_id) |> Repo.preload([:beacon, :user])
+ BeaconMonitor |> Repo.get!(monitor_id) |> Repo.preload([:beacon, :user, :assigned_by])
end
@doc """
@@ -203,6 +203,17 @@ defmodule Microwaveprop.BeaconMonitors do
BeaconMonitor.provision_changeset(%BeaconMonitor{}, attrs)
end
+ @doc """
+ Regenerates the monitor's auth token, returning the updated monitor
+ with its new token.
+ """
+ @spec regenerate_token(BeaconMonitor.t()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
+ def regenerate_token(%BeaconMonitor{} = monitor) do
+ monitor
+ |> Ecto.Changeset.change(%{token: generate_token()})
+ |> Repo.update()
+ end
+
defp generate_token do
@token_bytes
|> :crypto.strong_rand_bytes()
diff --git a/lib/microwaveprop_web/components/layouts.ex b/lib/microwaveprop_web/components/layouts.ex
index ec435238..bced63bc 100644
--- a/lib/microwaveprop_web/components/layouts.ex
+++ b/lib/microwaveprop_web/components/layouts.ex
@@ -76,6 +76,7 @@ defmodule MicrowavepropWeb.Layouts do
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-44 p-2 shadow-lg"
>
<.link navigate="/users">Users
+ <.link navigate="/admin/beacon-monitors">Beacon monitors
<.link navigate="/admin/contact-edits">Contact edits
<.link navigate="/status">Status
<.link href="/admin/oban">Oban
diff --git a/lib/microwaveprop_web/live/admin/monitor_live/index.ex b/lib/microwaveprop_web/live/admin/monitor_live/index.ex
index ace9eabb..ca3384ba 100644
--- a/lib/microwaveprop_web/live/admin/monitor_live/index.ex
+++ b/lib/microwaveprop_web/live/admin/monitor_live/index.ex
@@ -37,7 +37,7 @@ defmodule MicrowavepropWeb.Admin.MonitorLive.Index do
- <.button phx-click={JS.push("show-create-form", target: @myself)} variant="primary">
+ <.button phx-click={JS.push("show-create-form")} variant="primary">
<.icon name="hero-plus" class="size-4" /> New monitor
@@ -90,7 +90,7 @@ defmodule MicrowavepropWeb.Admin.MonitorLive.Index do
<.button variant="primary" phx-disable-with="Creating...">Create
- <.button phx-click={JS.push("hide-create-form", target: @myself)}>Cancel
+ <.button phx-click={JS.push("hide-create-form")}>Cancel
@@ -156,7 +156,7 @@ defmodule MicrowavepropWeb.Admin.MonitorLive.Index do
Detail
<.link
- phx-click={JS.push("delete", value: %{id: monitor.id}, target: @myself)}
+ phx-click={JS.push("delete", value: %{id: monitor.id})}
data-confirm={"Delete monitor '#{monitor.name}'? This cannot be undone."}
class="btn btn-xs btn-ghost text-error"
>
diff --git a/lib/microwaveprop_web/live/admin/monitor_live/show.ex b/lib/microwaveprop_web/live/admin/monitor_live/show.ex
index 0e440455..e857ba5f 100644
--- a/lib/microwaveprop_web/live/admin/monitor_live/show.ex
+++ b/lib/microwaveprop_web/live/admin/monitor_live/show.ex
@@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.Admin.MonitorLive.Show do
import Ecto.Query
+ alias Microwaveprop.Accounts
alias Microwaveprop.BeaconMeasurements
alias Microwaveprop.BeaconMonitors
alias Microwaveprop.BeaconMonitors.BeaconMonitor
@@ -14,6 +15,7 @@ defmodule MicrowavepropWeb.Admin.MonitorLive.Show do
def mount(%{"id" => id}, _session, socket) do
monitor = BeaconMonitors.get_monitor!(id)
beacons = Repo.all(from b in Beacon, order_by: b.callsign)
+ users = Accounts.list_users_select()
measurements = BeaconMeasurements.list_recent_for_monitor(monitor.id, 20)
{:ok,
@@ -21,8 +23,10 @@ defmodule MicrowavepropWeb.Admin.MonitorLive.Show do
|> assign(:page_title, "Monitor: #{monitor.name}")
|> assign(:monitor, monitor)
|> assign(:beacons, beacons)
+ |> assign(:users, users)
|> assign(:measurements, measurements)
- |> assign_form(monitor)}
+ |> assign_form(monitor)
+ |> assign_reassign_form(monitor)}
end
@impl true
@@ -82,7 +86,7 @@ defmodule MicrowavepropWeb.Admin.MonitorLive.Show do
<%= if @monitor.user do %>
<.link
- navigate={~p"/u/#{@monitor.user.callsign}"}
+ navigate={~p"/users/#{@monitor.user.id}/edit"}
class="link link-hover font-mono"
>
{@monitor.user.callsign}
@@ -92,10 +96,33 @@ defmodule MicrowavepropWeb.Admin.MonitorLive.Show do
<% end %>
+
+
Assigned by
+
+ <%= if @monitor.assigned_by do %>
+ <.link
+ navigate={~p"/users/#{@monitor.assigned_by.id}/edit"}
+ class="link link-hover font-mono"
+ >
+ {@monitor.assigned_by.callsign}
+
+ <% else %>
+ —
+ <% end %>
+
+
Auth token
{@monitor.token}
+ <.button
+ id="regenerate-token-btn"
+ phx-click={JS.push("regenerate-token")}
+ data-confirm="Regenerate token? The monitor will need the new token to connect."
+ class="btn btn-xs btn-ghost ml-1"
+ >
+ Regenerate
+
@@ -109,6 +136,25 @@ defmodule MicrowavepropWeb.Admin.MonitorLive.Show do
+
+
+ <.form for={@reassign_form} id="reassign-form" phx-submit="reassign">
+
+
+ <.input
+ field={@reassign_form[:user_id]}
+ type="select"
+ label="Reassign to"
+ options={@users}
+ prompt="Select user"
+ />
+
+ <.button variant="primary" phx-disable-with="Reassigning...">
+ Reassign
+
+
+
+
@@ -211,10 +257,71 @@ defmodule MicrowavepropWeb.Admin.MonitorLive.Show do
end
end
+ @impl true
+ def handle_event("reassign", %{"beacon_monitor" => %{"user_id" => user_id}}, socket)
+ when is_binary(user_id) and user_id != "" do
+ admin = socket.assigns.current_scope.user
+ monitor = socket.assigns.monitor
+
+ case Accounts.get_user!(user_id) do
+ nil ->
+ {:noreply, put_flash(socket, :error, "User not found.")}
+
+ target ->
+ case BeaconMonitors.assign_to_user(monitor, admin, target) do
+ {:ok, updated} ->
+ updated = Repo.preload(updated, [:user, :assigned_by])
+
+ {:noreply,
+ socket
+ |> assign(:monitor, updated)
+ |> assign_reassign_form(updated)
+ |> put_flash(:info, "Monitor reassigned to #{target.callsign}.")}
+
+ {:error, changeset} ->
+ {:noreply,
+ socket
+ |> assign_reassign_form(monitor, changeset)
+ |> put_flash(:error, "Failed to reassign monitor.")}
+ end
+ end
+ end
+
+ def handle_event("reassign", _params, socket) do
+ {:noreply, put_flash(socket, :error, "Please select a user.")}
+ end
+
+ @impl true
+ def handle_event("regenerate-token", _params, socket) do
+ monitor = socket.assigns.monitor
+
+ case BeaconMonitors.regenerate_token(monitor) do
+ {:ok, updated} ->
+ {:noreply,
+ socket
+ |> assign(:monitor, updated)
+ |> put_flash(:info, "Auth token regenerated.")}
+
+ {:error, _changeset} ->
+ {:noreply, put_flash(socket, :error, "Failed to regenerate token.")}
+ end
+ end
+
defp assign_form(socket, monitor) do
assign(socket, :form, to_form(BeaconMonitor.config_changeset(monitor, %{}), as: :beacon_monitor))
end
+ defp assign_reassign_form(socket, monitor, changeset \\ nil) do
+ form =
+ if changeset do
+ to_form(changeset, as: :beacon_monitor)
+ else
+ to_form(%{"user_id" => monitor.user_id}, as: :beacon_monitor)
+ end
+
+ assign(socket, :reassign_form, form)
+ end
+
defp beacon_options(beacons) do
Enum.map(beacons, &{"#{&1.callsign} @ #{Beacon.format_freq(&1.frequency_mhz)} MHz", &1.id})
end
diff --git a/lib/microwaveprop_web/live/monitor_live/show.ex b/lib/microwaveprop_web/live/monitor_live/show.ex
index 8248bcb6..79b4634a 100644
--- a/lib/microwaveprop_web/live/monitor_live/show.ex
+++ b/lib/microwaveprop_web/live/monitor_live/show.ex
@@ -30,7 +30,7 @@ defmodule MicrowavepropWeb.MonitorLive.Show do
{:ok,
socket
|> put_flash(:error, "You don't have access to that monitor.")
- |> push_navigate(to: ~p"/account")}
+ |> push_navigate(to: ~p"/users/settings")}
end
end
diff --git a/lib/microwaveprop_web/live/user_management_live/edit.ex b/lib/microwaveprop_web/live/user_management_live/edit.ex
index 2710463b..dc6426d2 100644
--- a/lib/microwaveprop_web/live/user_management_live/edit.ex
+++ b/lib/microwaveprop_web/live/user_management_live/edit.ex
@@ -1,8 +1,9 @@
defmodule MicrowavepropWeb.UserManagementLive.Edit do
- @moduledoc "Admin edit page for a single user (roles, suspension)."
+ @moduledoc "Admin edit page for a single user (roles, suspension, monitors)."
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts
+ alias Microwaveprop.BeaconMonitors
@impl true
def render(assigns) do
@@ -25,6 +26,66 @@ defmodule MicrowavepropWeb.UserManagementLive.Edit do
<.button navigate={~p"/users"}>Cancel
+
+
+
+
Beacon monitors
+
+ Monitor hardware assigned to this user.
+
+
+ <%= if @monitors == [] do %>
+
No monitors assigned to this user.
+ <% else %>
+
+
+
+
+ Name
+ Hardware
+ Last seen
+
+
+
+
+
+
+ <.link
+ navigate={~p"/admin/beacon-monitors/#{monitor.id}"}
+ class="link link-hover font-semibold"
+ >
+ {monitor.name}
+
+
+
+ {monitor.hardware_type}
+
+ ({monitor.hardware_id})
+
+
+
+ <%= if monitor.last_seen_at do %>
+ {Calendar.strftime(monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
+ <% else %>
+ never
+ <% end %>
+
+
+ <.link
+ phx-click={JS.push("unassign-monitor", value: %{id: monitor.id})}
+ data-confirm={"Remove monitor '#{monitor.name}' from #{@user.callsign}?"}
+ class="btn btn-xs btn-ghost text-error"
+ >
+ Remove
+
+
+
+
+
+
+ <% end %>
+
+
"""
end
@@ -39,10 +100,13 @@ defmodule MicrowavepropWeb.UserManagementLive.Edit do
|> push_navigate(to: ~p"/users")}
user ->
+ monitors = BeaconMonitors.list_monitors_for_user(user)
+
{:ok,
socket
|> assign(:page_title, "Edit user")
|> assign(:user, user)
+ |> assign(:monitors, monitors)
|> assign(:form, to_form(Accounts.change_admin_user(user)))}
end
end
@@ -53,6 +117,7 @@ defmodule MicrowavepropWeb.UserManagementLive.Edit do
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
+ @impl true
def handle_event("save", %{"user" => params}, socket) do
case Accounts.admin_update_user(socket.assigns.user, params) do
{:ok, _user} ->
@@ -65,4 +130,22 @@ defmodule MicrowavepropWeb.UserManagementLive.Edit do
{:noreply, assign(socket, form: to_form(changeset))}
end
end
+
+ @impl true
+ def handle_event("unassign-monitor", %{"id" => monitor_id}, socket) do
+ user = socket.assigns.user
+
+ case BeaconMonitors.delete_monitor!(monitor_id) do
+ {:ok, monitor} ->
+ monitors = BeaconMonitors.list_monitors_for_user(user)
+
+ {:noreply,
+ socket
+ |> assign(:monitors, monitors)
+ |> put_flash(:info, "Monitor '#{monitor.name}' removed from #{user.callsign}.")}
+
+ {:error, :not_found} ->
+ {:noreply, put_flash(socket, :error, "Monitor not found.")}
+ end
+ end
end
diff --git a/test/microwaveprop/accounts_test.exs b/test/microwaveprop/accounts_test.exs
index 9a252557..b4089bdd 100644
--- a/test/microwaveprop/accounts_test.exs
+++ b/test/microwaveprop/accounts_test.exs
@@ -555,4 +555,16 @@ defmodule Microwaveprop.AccountsTest do
refute inspect(changeset.data) =~ "123456"
end
end
+
+ describe "list_users_select/0" do
+ test "returns callsign/id pairs ordered by callsign" do
+ u1 = user_fixture(callsign: "W5AAA")
+ u2 = user_fixture(callsign: "K1BBB")
+
+ result = Accounts.list_users_select()
+ assert is_list(result)
+ assert result |> Enum.find(fn {_cs, id} -> id == u1.id end) |> elem(0) == "W5AAA"
+ assert result |> Enum.find(fn {_cs, id} -> id == u2.id end) |> elem(0) == "K1BBB"
+ end
+ end
end
diff --git a/test/microwaveprop/beacon_monitors_test.exs b/test/microwaveprop/beacon_monitors_test.exs
index 298d12d8..ee5c8639 100644
--- a/test/microwaveprop/beacon_monitors_test.exs
+++ b/test/microwaveprop/beacon_monitors_test.exs
@@ -110,4 +110,17 @@ defmodule Microwaveprop.BeaconMonitorsTest do
refute BeaconMonitors.get_monitor_by_token("not-a-real-token")
end
end
+
+ describe "regenerate_token/1" do
+ test "generates a new token on the monitor" do
+ user = user_fixture()
+ {:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Token Test"})
+ original_token = monitor.token
+
+ {:ok, updated} = BeaconMonitors.regenerate_token(monitor)
+ assert updated.id == monitor.id
+ assert updated.token != original_token
+ assert String.length(updated.token) == 43
+ end
+ end
end
diff --git a/test/microwaveprop_web/live/admin/monitor_live_test.exs b/test/microwaveprop_web/live/admin/monitor_live_test.exs
new file mode 100644
index 00000000..84efab7e
--- /dev/null
+++ b/test/microwaveprop_web/live/admin/monitor_live_test.exs
@@ -0,0 +1,73 @@
+defmodule MicrowavepropWeb.Admin.MonitorLiveTest do
+ use MicrowavepropWeb.ConnCase, async: false
+
+ import Microwaveprop.AccountsFixtures
+ import Phoenix.LiveViewTest
+
+ alias Microwaveprop.Accounts
+ alias Microwaveprop.BeaconMonitors
+
+ defp admin_and_monitor do
+ admin = user_fixture()
+ {:ok, admin} = Accounts.admin_update_user(admin, %{is_admin: true})
+ target = user_fixture(callsign: "W5MON")
+ {:ok, monitor} = BeaconMonitors.create_monitor(target, %{"name" => "Test Monitor"})
+ {admin, target, monitor}
+ end
+
+ describe "index" do
+ test "lists all monitors", %{conn: conn} do
+ {admin, _target, monitor} = admin_and_monitor()
+ conn = log_in_user(conn, admin)
+
+ {:ok, _lv, html} = live(conn, ~p"/admin/beacon-monitors")
+ assert html =~ monitor.name
+ end
+
+ test "redirects non-admin to /", %{conn: conn} do
+ conn = log_in_user(conn, user_fixture())
+ assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/admin/beacon-monitors")
+ end
+ end
+
+ describe "show" do
+ test "shows monitor details", %{conn: conn} do
+ {admin, target, monitor} = admin_and_monitor()
+ conn = log_in_user(conn, admin)
+
+ {:ok, _lv, html} = live(conn, ~p"/admin/beacon-monitors/#{monitor.id}")
+ assert html =~ monitor.name
+ assert html =~ monitor.token
+ assert html =~ target.callsign
+ end
+
+ test "reassigns monitor to a different user", %{conn: conn} do
+ {admin, _target, monitor} = admin_and_monitor()
+ new_user = user_fixture(callsign: "W5NEW")
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, _html} = live(conn, ~p"/admin/beacon-monitors/#{monitor.id}")
+
+ html =
+ lv
+ |> form("#reassign-form", beacon_monitor: %{user_id: new_user.id})
+ |> render_submit()
+
+ assert html =~ "Monitor reassigned to #{new_user.callsign}"
+ end
+
+ test "regenerates the auth token", %{conn: conn} do
+ {admin, _target, monitor} = admin_and_monitor()
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, _html} = live(conn, ~p"/admin/beacon-monitors/#{monitor.id}")
+
+ html =
+ lv
+ |> element("#regenerate-token-btn")
+ |> render_click()
+
+ assert html =~ "Auth token regenerated"
+ end
+ end
+end
diff --git a/test/microwaveprop_web/live/user_management_live_test.exs b/test/microwaveprop_web/live/user_management_live_test.exs
index 83dbc54c..32f9f465 100644
--- a/test/microwaveprop_web/live/user_management_live_test.exs
+++ b/test/microwaveprop_web/live/user_management_live_test.exs
@@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.UserManagementLiveTest do
import Phoenix.LiveViewTest
alias Microwaveprop.Accounts
+ alias Microwaveprop.BeaconMonitors
defp admin_user_fixture do
user = user_fixture()
@@ -68,4 +69,32 @@ defmodule MicrowavepropWeb.UserManagementLiveTest do
assert Accounts.get_user!(target.id).is_admin
end
end
+
+ describe "Edit monitors" do
+ test "shows monitors assigned to user", %{conn: conn} do
+ admin = admin_user_fixture()
+ target = user_fixture(callsign: "W5MON")
+ {:ok, _monitor} = BeaconMonitors.create_monitor(target, %{"name" => "Shack Pi"})
+ conn = log_in_user(conn, admin)
+
+ {:ok, _lv, html} = live(conn, ~p"/users/#{target.id}/edit")
+ assert html =~ "Shack Pi"
+ assert html =~ "Beacon monitors"
+ end
+
+ test "admin can remove a monitor from a user", %{conn: conn} do
+ admin = admin_user_fixture()
+ target = user_fixture(callsign: "W5MON")
+ {:ok, monitor} = BeaconMonitors.create_monitor(target, %{"name" => "Remove Me"})
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, html_before} = live(conn, ~p"/users/#{target.id}/edit")
+ assert html_before =~ "Remove Me"
+
+ render_click(lv, "unassign-monitor", %{"id" => monitor.id})
+
+ monitors = BeaconMonitors.list_monitors_for_user(target)
+ assert monitors == []
+ end
+ end
end