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 @@ -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 %> +
    + + + + + + + + + + + + + + + + + +
    NameHardwareLast 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