From ddec874c381e4348df3243171143cbeb20c44e34 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 8 Apr 2026 12:01:34 -0500 Subject: [PATCH] Add /beacons CRUD and /users admin page Beacons: - Scaffolded with phx.gen.live then reworked so reads are public and mutations go through a live_session gated by the new :require_admin on_mount hook in UserAuth - Beacon schema stores frequency (MHz), callsign, grid, lat/lon, power (W), and height above ground (m); grid auto-derives from lat/lon when left blank via Maidenhead.from_latlon - Adds Maidenhead.from_latlon/3 so we can compute grids locally instead of hitting an external API Users admin page: - /users and /users/:id/edit (admin-only) for listing, editing (callsign/name/email/is_admin), and deleting other users - Adds Accounts.list_users, admin_update_user, delete_user, and a dedicated admin_changeset on the User schema - Nav gains a "Users" link for admins and a "Beacons" link for everyone --- lib/microwaveprop/accounts.ex | 22 +++ lib/microwaveprop/accounts/user.ex | 19 +++ lib/microwaveprop/beacons.ex | 80 +++++++++++ lib/microwaveprop/beacons/beacon.ex | 86 ++++++++++++ lib/microwaveprop/radio/maidenhead.ex | 47 +++++++ lib/microwaveprop_web/components/layouts.ex | 8 ++ .../live/beacon_live/form.ex | 121 ++++++++++++++++ .../live/beacon_live/index.ex | 81 +++++++++++ .../live/beacon_live/show.ex | 70 ++++++++++ .../live/user_management_live/edit.ex | 61 ++++++++ .../live/user_management_live/index.ex | 70 ++++++++++ lib/microwaveprop_web/router.ex | 18 +++ lib/microwaveprop_web/user_auth.ex | 19 +++ .../20260408165341_create_beacons.exs | 21 +++ test/microwaveprop/beacons_test.exs | 132 ++++++++++++++++++ test/microwaveprop/radio/maidenhead_test.exs | 28 ++++ .../live/beacon_live_test.exs | 94 +++++++++++++ .../live/user_management_live_test.exs | 71 ++++++++++ test/support/fixtures/beacons_fixtures.ex | 23 +++ 19 files changed, 1071 insertions(+) create mode 100644 lib/microwaveprop/beacons.ex create mode 100644 lib/microwaveprop/beacons/beacon.ex create mode 100644 lib/microwaveprop_web/live/beacon_live/form.ex create mode 100644 lib/microwaveprop_web/live/beacon_live/index.ex create mode 100644 lib/microwaveprop_web/live/beacon_live/show.ex create mode 100644 lib/microwaveprop_web/live/user_management_live/edit.ex create mode 100644 lib/microwaveprop_web/live/user_management_live/index.ex create mode 100644 priv/repo/migrations/20260408165341_create_beacons.exs create mode 100644 test/microwaveprop/beacons_test.exs create mode 100644 test/microwaveprop_web/live/beacon_live_test.exs create mode 100644 test/microwaveprop_web/live/user_management_live_test.exs create mode 100644 test/support/fixtures/beacons_fixtures.ex diff --git a/lib/microwaveprop/accounts.ex b/lib/microwaveprop/accounts.ex index 346ebeed..69400fdf 100644 --- a/lib/microwaveprop/accounts.ex +++ b/lib/microwaveprop/accounts.ex @@ -61,6 +61,28 @@ defmodule Microwaveprop.Accounts do """ def get_user!(id), do: Repo.get!(User, id) + ## Admin user management + + @doc "Returns all users ordered by callsign." + def list_users do + Repo.all(from u in User, order_by: [asc: u.callsign]) + end + + @doc "Updates admin-managed user fields (callsign, name, email, is_admin)." + def admin_update_user(%User{} = user, attrs) do + user + |> User.admin_changeset(attrs) + |> Repo.update() + end + + @doc "Returns an admin-edit changeset for rendering forms." + def change_admin_user(%User{} = user, attrs \\ %{}) do + User.admin_changeset(user, attrs) + end + + @doc "Deletes a user." + def delete_user(%User{} = user), do: Repo.delete(user) + ## User registration @doc """ diff --git a/lib/microwaveprop/accounts/user.ex b/lib/microwaveprop/accounts/user.ex index bd6c2212..99973c8a 100644 --- a/lib/microwaveprop/accounts/user.ex +++ b/lib/microwaveprop/accounts/user.ex @@ -59,6 +59,25 @@ defmodule Microwaveprop.Accounts.User do end end + @doc """ + Admin-only changeset for editing another user's profile fields + and admin flag. Does not touch the password. + """ + def admin_changeset(user, attrs) do + user + |> cast(attrs, [:callsign, :name, :email, :is_admin]) + |> update_change(:callsign, fn cs -> cs && cs |> String.trim() |> String.upcase() end) + |> validate_required([:callsign, :name, :email]) + |> validate_format(:callsign, ~r/^[A-Z0-9]{3,10}$/, message: "must be 3-10 letters and digits") + |> validate_length(:name, min: 1, max: 100) + |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> unsafe_validate_unique(:callsign, Microwaveprop.Repo) + |> unique_constraint(:callsign) + |> unsafe_validate_unique(:email, Microwaveprop.Repo) + |> unique_constraint(:email) + end + defp validate_callsign(changeset, opts) do changeset = changeset diff --git a/lib/microwaveprop/beacons.ex b/lib/microwaveprop/beacons.ex new file mode 100644 index 00000000..7574eb2f --- /dev/null +++ b/lib/microwaveprop/beacons.ex @@ -0,0 +1,80 @@ +defmodule Microwaveprop.Beacons do + @moduledoc """ + The Beacons context. Beacon records are public (anyone can read) + but only admin users can create/update/delete them — that gating + is enforced by the router's live_session for the form views. + """ + + import Ecto.Query, warn: false + + alias Microwaveprop.Accounts.User + alias Microwaveprop.Beacons.Beacon + alias Microwaveprop.Repo + + @topic "beacons" + + @doc """ + Subscribes to beacon change notifications. Messages: + + * `{:created, %Beacon{}}` + * `{:updated, %Beacon{}}` + * `{:deleted, %Beacon{}}` + """ + def subscribe_beacons do + Phoenix.PubSub.subscribe(Microwaveprop.PubSub, @topic) + end + + defp broadcast(message) do + Phoenix.PubSub.broadcast(Microwaveprop.PubSub, @topic, message) + end + + @doc "Returns all beacons ordered by frequency." + def list_beacons do + Repo.all(from b in Beacon, order_by: [asc: b.frequency_mhz, asc: b.callsign]) + end + + @doc "Gets a single beacon. Raises if not found." + def get_beacon!(id), do: Repo.get!(Beacon, id) + + @doc """ + Creates a beacon. The user is recorded on the row as the creator. + """ + def create_beacon(%User{} = user, attrs) do + %Beacon{user_id: user.id} + |> Beacon.changeset(attrs) + |> Repo.insert() + |> broadcast_if_ok(:created) + end + + @doc "Updates a beacon." + def update_beacon(%Beacon{} = beacon, attrs) do + beacon + |> Beacon.changeset(attrs) + |> Repo.update() + |> broadcast_if_ok(:updated) + end + + @doc "Deletes a beacon." + def delete_beacon(%Beacon{} = beacon) do + case Repo.delete(beacon) do + {:ok, beacon} -> + broadcast({:deleted, beacon}) + {:ok, beacon} + + other -> + other + end + end + + @doc "Returns an `%Ecto.Changeset{}` for tracking beacon changes." + def change_beacon(%Beacon{} = beacon, attrs \\ %{}) do + Beacon.changeset(beacon, attrs) + end + + defp broadcast_if_ok({:ok, beacon} = result, type) do + broadcast({type, beacon}) + result + end + + defp broadcast_if_ok(other, _type), do: other +end diff --git a/lib/microwaveprop/beacons/beacon.ex b/lib/microwaveprop/beacons/beacon.ex new file mode 100644 index 00000000..1db1bb3d --- /dev/null +++ b/lib/microwaveprop/beacons/beacon.ex @@ -0,0 +1,86 @@ +defmodule Microwaveprop.Beacons.Beacon do + @moduledoc """ + A beacon transmitter. Frequency in MHz, coordinates as lat/lon, + grid as Maidenhead (auto-computed from lat/lon when not supplied), + power in watts, height above ground in meters. + """ + + use Ecto.Schema + + import Ecto.Changeset + + alias Microwaveprop.Radio.Maidenhead + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "beacons" do + field :frequency_mhz, :float + field :callsign, :string + field :grid, :string + field :lat, :float + field :lon, :float + field :power_watts, :float + field :height_m, :float + field :user_id, :binary_id + + timestamps(type: :utc_datetime) + end + + @required_fields [:frequency_mhz, :callsign, :lat, :lon, :power_watts, :height_m] + + def changeset(beacon, attrs) do + beacon + |> cast(attrs, [:frequency_mhz, :callsign, :grid, :lat, :lon, :power_watts, :height_m]) + |> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end) + |> validate_required(@required_fields) + |> validate_number(:frequency_mhz, greater_than: 0) + |> validate_number(:power_watts, greater_than_or_equal_to: 0) + |> validate_number(:height_m, greater_than_or_equal_to: 0) + |> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90) + |> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180) + |> validate_length(:callsign, min: 3, max: 10) + |> maybe_fill_grid() + |> validate_grid_format() + end + + # If grid is blank but lat/lon are valid, derive it. + defp maybe_fill_grid(changeset) do + grid = get_field(changeset, :grid) + lat = get_field(changeset, :lat) + lon = get_field(changeset, :lon) + + if grid in [nil, ""] and is_number(lat) and is_number(lon) do + put_change(changeset, :grid, Maidenhead.from_latlon(lat, lon, 6)) + else + changeset + end + end + + defp validate_grid_format(changeset) do + case get_field(changeset, :grid) do + nil -> + add_error(changeset, :grid, "can't be blank") + + grid -> + if Maidenhead.valid?(grid) do + update_change(changeset, :grid, &normalize_grid/1) + else + add_error(changeset, :grid, "is not a valid Maidenhead grid") + end + end + end + + # Field uppercase, square digits, subsquare lowercase (standard form) + defp normalize_grid(nil), do: nil + + defp normalize_grid(grid) do + grid = String.trim(grid) + len = String.length(grid) + + if len >= 6 do + grid |> String.slice(0, 4) |> String.upcase() |> Kernel.<>(String.downcase(String.slice(grid, 4, len - 4))) + else + String.upcase(grid) + end + end +end diff --git a/lib/microwaveprop/radio/maidenhead.ex b/lib/microwaveprop/radio/maidenhead.ex index b532af3d..0064c95c 100644 --- a/lib/microwaveprop/radio/maidenhead.ex +++ b/lib/microwaveprop/radio/maidenhead.ex @@ -46,6 +46,53 @@ defmodule Microwaveprop.Radio.Maidenhead do def to_latlon(_), do: :error + @doc """ + Converts a lat/lon pair into a Maidenhead grid at the requested + precision (must be an even number between 4 and 8). Default precision + is 6 (subsquare level, ~5'×2.5', ~7 km × 4.6 km at the equator). + """ + @spec from_latlon(number(), number(), pos_integer()) :: String.t() + def from_latlon(lat, lon, precision \\ 6) when is_number(lat) and is_number(lon) and precision in [4, 6, 8] do + lat = lat + 90.0 + lon = lon + 180.0 + + # Field (A-R), 20° lon / 10° lat + f1 = trunc(lon / 20.0) + f2 = trunc(lat / 10.0) + lon = lon - f1 * 20.0 + lat = lat - f2 * 10.0 + + # Square (0-9), 2° lon / 1° lat + s1 = trunc(lon / 2.0) + s2 = trunc(lat / 1.0) + lon = lon - s1 * 2.0 + lat = lat - s2 * 1.0 + + grid = <> + + cond do + precision == 4 -> + grid + + precision >= 6 -> + # Subsquare (a-x), 5'/60 lon, 2.5'/60 lat + ss1 = trunc(lon * 60.0 / 5.0) + ss2 = trunc(lat * 60.0 / 2.5) + lon = lon - ss1 * (5.0 / 60.0) + lat = lat - ss2 * (2.5 / 60.0) + grid = grid <> <> + + if precision == 8 do + # Extended square (0-9), (5/60)/10 lon, (2.5/60)/10 lat + e1 = trunc(lon * 60.0 / 0.5) + e2 = trunc(lat * 60.0 / 0.25) + grid <> <> + else + grid + end + end + end + # Decode remaining alternating letter(24)/digit(10) pairs after field+square defp decode_pairs([c1, c2 | rest], lat, lon, lat_size, lon_size, :letter) do cell_lon = lon_size / 24 diff --git a/lib/microwaveprop_web/components/layouts.ex b/lib/microwaveprop_web/components/layouts.ex index 0c55d563..b93fe982 100644 --- a/lib/microwaveprop_web/components/layouts.ex +++ b/lib/microwaveprop_web/components/layouts.ex @@ -43,9 +43,17 @@ defmodule MicrowavepropWeb.Layouts do <.link navigate="/map" class="btn btn-ghost btn-sm">Map <.link navigate="/path" class="btn btn-ghost btn-sm">Path <.link navigate="/rover" class="btn btn-ghost btn-sm">Rover + <.link navigate="/beacons" class="btn btn-ghost btn-sm">Beacons <.link navigate="/contacts/map" class="btn btn-ghost btn-sm">Contact Map <.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts <.link navigate="/submit" class="btn btn-ghost btn-sm">Submit + <.link + :if={@current_scope && @current_scope.user && @current_scope.user.is_admin} + navigate="/users" + class="btn btn-ghost btn-sm" + > + Users + <%= if @current_scope && @current_scope.user do %> {@current_scope.user.callsign} diff --git a/lib/microwaveprop_web/live/beacon_live/form.ex b/lib/microwaveprop_web/live/beacon_live/form.ex new file mode 100644 index 00000000..516a8fc1 --- /dev/null +++ b/lib/microwaveprop_web/live/beacon_live/form.ex @@ -0,0 +1,121 @@ +defmodule MicrowavepropWeb.BeaconLive.Form do + @moduledoc false + use MicrowavepropWeb, :live_view + + alias Microwaveprop.Beacons + alias Microwaveprop.Beacons.Beacon + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@page_title} + <:subtitle>Leave the grid blank to auto-fill it from lat/lon. + + + <.form for={@form} id="beacon-form" phx-change="validate" phx-submit="save"> + <.input + field={@form[:frequency_mhz]} + type="number" + label="Frequency (MHz)" + step="any" + required + /> + <.input field={@form[:callsign]} type="text" label="Call" required /> + <.input field={@form[:grid]} type="text" label="Grid (auto if blank)" /> + <.input field={@form[:lat]} type="number" label="Latitude" step="any" required /> + <.input field={@form[:lon]} type="number" label="Longitude" step="any" required /> + <.input + field={@form[:power_watts]} + type="number" + label="Power (W)" + step="any" + required + /> + <.input + field={@form[:height_m]} + type="number" + label="Height above ground (m)" + step="any" + required + /> +
+ <.button phx-disable-with="Saving..." variant="primary">Save Beacon + <.button navigate={return_path(@return_to, @beacon)}>Cancel +
+ +
+ """ + end + + @impl true + def mount(params, _session, socket) do + {:ok, + socket + |> assign(:return_to, return_to(params["return_to"])) + |> apply_action(socket.assigns.live_action, params)} + end + + defp return_to("show"), do: "show" + defp return_to(_), do: "index" + + defp apply_action(socket, :edit, %{"id" => id}) do + beacon = Beacons.get_beacon!(id) + + socket + |> assign(:page_title, "Edit beacon") + |> assign(:beacon, beacon) + |> assign(:form, to_form(Beacons.change_beacon(beacon))) + end + + defp apply_action(socket, :new, _params) do + beacon = %Beacon{} + + socket + |> assign(:page_title, "New beacon") + |> assign(:beacon, beacon) + |> assign(:form, to_form(Beacons.change_beacon(beacon))) + end + + @impl true + def handle_event("validate", %{"beacon" => beacon_params}, socket) do + changeset = Beacons.change_beacon(socket.assigns.beacon, beacon_params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"beacon" => beacon_params}, socket) do + save_beacon(socket, socket.assigns.live_action, beacon_params) + end + + defp save_beacon(socket, :edit, beacon_params) do + case Beacons.update_beacon(socket.assigns.beacon, beacon_params) do + {:ok, beacon} -> + {:noreply, + socket + |> put_flash(:info, "Beacon updated") + |> push_navigate(to: return_path(socket.assigns.return_to, beacon))} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_beacon(socket, :new, beacon_params) do + user = socket.assigns.current_scope.user + + case Beacons.create_beacon(user, beacon_params) do + {:ok, beacon} -> + {:noreply, + socket + |> put_flash(:info, "Beacon created") + |> push_navigate(to: return_path(socket.assigns.return_to, beacon))} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp return_path("index", _beacon), do: ~p"/beacons" + defp return_path("show", beacon), do: ~p"/beacons/#{beacon}" +end diff --git a/lib/microwaveprop_web/live/beacon_live/index.ex b/lib/microwaveprop_web/live/beacon_live/index.ex new file mode 100644 index 00000000..5ee6880c --- /dev/null +++ b/lib/microwaveprop_web/live/beacon_live/index.ex @@ -0,0 +1,81 @@ +defmodule MicrowavepropWeb.BeaconLive.Index do + @moduledoc false + use MicrowavepropWeb, :live_view + + alias Microwaveprop.Beacons + + @impl true + def render(assigns) do + ~H""" + + <.header> + Beacons + <:subtitle>Microwave beacons tracked by NTMS. + <:actions> + <.button :if={admin?(@current_scope)} variant="primary" navigate={~p"/beacons/new"}> + <.icon name="hero-plus" /> New Beacon + + + + + <.table + id="beacons" + rows={@streams.beacons} + row_click={fn {_id, beacon} -> JS.navigate(~p"/beacons/#{beacon}") end} + > + <:col :let={{_id, beacon}} label="Frequency (MHz)">{beacon.frequency_mhz} + <:col :let={{_id, beacon}} label="Call">{beacon.callsign} + <:col :let={{_id, beacon}} label="Grid">{beacon.grid} + <:col :let={{_id, beacon}} label="Lat">{beacon.lat} + <:col :let={{_id, beacon}} label="Lon">{beacon.lon} + <:col :let={{_id, beacon}} label="Power (W)">{beacon.power_watts} + <:col :let={{_id, beacon}} label="Height AGL (m)">{beacon.height_m} + <:action :let={{_id, beacon}}> +
+ <.link navigate={~p"/beacons/#{beacon}"}>Show +
+ <.link :if={admin?(@current_scope)} navigate={~p"/beacons/#{beacon}/edit"}>Edit + + <:action :let={{id, beacon}}> + <.link + :if={admin?(@current_scope)} + phx-click={JS.push("delete", value: %{id: beacon.id}) |> hide("##{id}")} + data-confirm="Delete this beacon?" + > + Delete + + + +
+ """ + end + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: Beacons.subscribe_beacons() + + {:ok, + socket + |> assign(:page_title, "Beacons") + |> stream(:beacons, Beacons.list_beacons())} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + if admin?(socket.assigns.current_scope) do + beacon = Beacons.get_beacon!(id) + {:ok, _} = Beacons.delete_beacon(beacon) + {:noreply, stream_delete(socket, :beacons, beacon)} + else + {:noreply, put_flash(socket, :error, "Admins only.")} + end + end + + @impl true + def handle_info({type, %Microwaveprop.Beacons.Beacon{}}, socket) when type in [:created, :updated, :deleted] do + {:noreply, stream(socket, :beacons, Beacons.list_beacons(), reset: true)} + end + + defp admin?(%{user: %{is_admin: true}}), do: true + defp admin?(_), do: false +end diff --git a/lib/microwaveprop_web/live/beacon_live/show.ex b/lib/microwaveprop_web/live/beacon_live/show.ex new file mode 100644 index 00000000..fb627ae8 --- /dev/null +++ b/lib/microwaveprop_web/live/beacon_live/show.ex @@ -0,0 +1,70 @@ +defmodule MicrowavepropWeb.BeaconLive.Show do + @moduledoc false + use MicrowavepropWeb, :live_view + + alias Microwaveprop.Beacons + alias Microwaveprop.Beacons.Beacon + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@beacon.callsign} + <:subtitle>{@beacon.frequency_mhz} MHz · {@beacon.grid} + <:actions> + <.button navigate={~p"/beacons"}> + <.icon name="hero-arrow-left" /> + + <.button + :if={admin?(@current_scope)} + variant="primary" + navigate={~p"/beacons/#{@beacon}/edit?return_to=show"} + > + <.icon name="hero-pencil-square" /> Edit + + + + + <.list> + <:item title="Frequency (MHz)">{@beacon.frequency_mhz} + <:item title="Callsign">{@beacon.callsign} + <:item title="Grid">{@beacon.grid} + <:item title="Latitude">{@beacon.lat} + <:item title="Longitude">{@beacon.lon} + <:item title="Power (W)">{@beacon.power_watts} + <:item title="Height above ground (m)">{@beacon.height_m} + + + """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + if connected?(socket), do: Beacons.subscribe_beacons() + + {:ok, + socket + |> assign(:page_title, "Beacon") + |> assign(:beacon, Beacons.get_beacon!(id))} + end + + @impl true + def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do + {:noreply, assign(socket, :beacon, beacon)} + end + + def handle_info({:deleted, %Beacon{id: id}}, %{assigns: %{beacon: %{id: id}}} = socket) do + {:noreply, + socket + |> put_flash(:error, "This beacon was deleted.") + |> push_navigate(to: ~p"/beacons")} + end + + def handle_info({type, %Beacon{}}, socket) when type in [:created, :updated, :deleted] do + {:noreply, socket} + end + + defp admin?(%{user: %{is_admin: true}}), do: true + defp admin?(_), do: false +end diff --git a/lib/microwaveprop_web/live/user_management_live/edit.ex b/lib/microwaveprop_web/live/user_management_live/edit.ex new file mode 100644 index 00000000..64a14ce1 --- /dev/null +++ b/lib/microwaveprop_web/live/user_management_live/edit.ex @@ -0,0 +1,61 @@ +defmodule MicrowavepropWeb.UserManagementLive.Edit do + @moduledoc false + use MicrowavepropWeb, :live_view + + alias Microwaveprop.Accounts + + @impl true + def render(assigns) do + ~H""" + + <.header> + Edit user + <:subtitle>{@user.callsign} · {@user.email} + + + <.form for={@form} id="user-form" phx-change="validate" phx-submit="save"> + <.input field={@form[:callsign]} type="text" label="Callsign" required /> + <.input field={@form[:name]} type="text" label="Name" required /> + <.input field={@form[:email]} type="email" label="Email" required /> + +
+ <.button variant="primary" phx-disable-with="Saving...">Save + <.button navigate={~p"/users"}>Cancel +
+ +
+ """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + user = Accounts.get_user!(id) + + {:ok, + socket + |> assign(:page_title, "Edit user") + |> assign(:user, user) + |> assign(:form, to_form(Accounts.change_admin_user(user)))} + end + + @impl true + def handle_event("validate", %{"user" => params}, socket) do + changeset = Accounts.change_admin_user(socket.assigns.user, params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"user" => params}, socket) do + case Accounts.admin_update_user(socket.assigns.user, params) do + {:ok, _user} -> + {:noreply, + socket + |> put_flash(:info, "User updated") + |> push_navigate(to: ~p"/users")} + + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end +end diff --git a/lib/microwaveprop_web/live/user_management_live/index.ex b/lib/microwaveprop_web/live/user_management_live/index.ex new file mode 100644 index 00000000..96808825 --- /dev/null +++ b/lib/microwaveprop_web/live/user_management_live/index.ex @@ -0,0 +1,70 @@ +defmodule MicrowavepropWeb.UserManagementLive.Index do + @moduledoc false + use MicrowavepropWeb, :live_view + + alias Microwaveprop.Accounts + + @impl true + def render(assigns) do + ~H""" + + <.header> + Users + <:subtitle>Manage registered accounts and admin flags. + + + <.table id="users" rows={@streams.users}> + <:col :let={{_id, user}} label="Callsign">{user.callsign} + <:col :let={{_id, user}} label="Name">{user.name} + <:col :let={{_id, user}} label="Email">{user.email} + <:col :let={{_id, user}} label="Admin"> + <%= if user.is_admin do %> + admin + <% else %> + + <% end %> + + <:col :let={{_id, user}} label="Confirmed"> + <%= if user.confirmed_at do %> + {Calendar.strftime(user.confirmed_at, "%Y-%m-%d")} + <% else %> + pending + <% end %> + + <:action :let={{_id, user}}> + <.link navigate={~p"/users/#{user.id}/edit"}>Edit + + <:action :let={{id, user}}> + <.link + :if={user.id != @current_scope.user.id} + phx-click={JS.push("delete", value: %{id: user.id}) |> hide("##{id}")} + data-confirm={"Delete user #{user.callsign}? This cannot be undone."} + > + Delete + + + + + """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Users") + |> stream(:users, Accounts.list_users())} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + user = Accounts.get_user!(id) + + if user.id == socket.assigns.current_scope.user.id do + {:noreply, put_flash(socket, :error, "You cannot delete your own account here.")} + else + {:ok, _} = Accounts.delete_user(user) + {:noreply, stream_delete(socket, :users, user)} + end + end +end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index aa8d8d97..abc297d8 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -27,6 +27,20 @@ defmodule MicrowavepropWeb.Router do # Health check — no pipeline, minimal overhead get "/health", MicrowavepropWeb.HealthController, :check, log: false + # Admin-only routes must come first so that literal segments like + # `/beacons/new` are registered before the public `/beacons/:id`. + scope "/", MicrowavepropWeb do + pipe_through [:browser, :require_authenticated_user] + + live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do + live "/beacons/new", BeaconLive.Form, :new + live "/beacons/:id/edit", BeaconLive.Form, :edit + + live "/users", UserManagementLive.Index, :index + live "/users/:id/edit", UserManagementLive.Edit, :edit + end + end + scope "/", MicrowavepropWeb do pipe_through :browser @@ -43,6 +57,10 @@ defmodule MicrowavepropWeb.Router do live "/algo", AlgoLive live "/backfill", BackfillLive + # Beacons: read-only public views + live "/beacons", BeaconLive.Index, :index + live "/beacons/:id", BeaconLive.Show, :show + # Redirect old /qsos routes get "/qsos", PageController, :redirect_contacts get "/qsos/:id", PageController, :redirect_contact diff --git a/lib/microwaveprop_web/user_auth.ex b/lib/microwaveprop_web/user_auth.ex index 442ac611..2c782ac1 100644 --- a/lib/microwaveprop_web/user_auth.ex +++ b/lib/microwaveprop_web/user_auth.ex @@ -21,11 +21,30 @@ defmodule MicrowavepropWeb.UserAuth do @doc """ LiveView `on_mount` callback that assigns `:current_scope` from the session token so LiveView templates can show auth-aware navigation. + + The `:require_admin` variant halts and redirects users who are not + logged in or not flagged as admin. Use it via `live_session`. """ def on_mount(:default, _params, session, socket) do {:cont, mount_current_scope(socket, session)} end + def on_mount(:require_admin, _params, session, socket) do + socket = mount_current_scope(socket, session) + user = socket.assigns.current_scope && socket.assigns.current_scope.user + + if user && user.is_admin do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must be an admin to access that page.") + |> Phoenix.LiveView.redirect(to: ~p"/") + + {:halt, socket} + end + end + defp mount_current_scope(socket, session) do Phoenix.Component.assign_new(socket, :current_scope, fn -> user = diff --git a/priv/repo/migrations/20260408165341_create_beacons.exs b/priv/repo/migrations/20260408165341_create_beacons.exs new file mode 100644 index 00000000..16114dfe --- /dev/null +++ b/priv/repo/migrations/20260408165341_create_beacons.exs @@ -0,0 +1,21 @@ +defmodule Microwaveprop.Repo.Migrations.CreateBeacons do + use Ecto.Migration + + def change do + create table(:beacons, primary_key: false) do + add :id, :binary_id, primary_key: true + add :frequency_mhz, :float + add :callsign, :string + add :grid, :string + add :lat, :float + add :lon, :float + add :power_watts, :float + add :height_m, :float + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create index(:beacons, [:user_id]) + end +end diff --git a/test/microwaveprop/beacons_test.exs b/test/microwaveprop/beacons_test.exs new file mode 100644 index 00000000..cf8d32b1 --- /dev/null +++ b/test/microwaveprop/beacons_test.exs @@ -0,0 +1,132 @@ +defmodule Microwaveprop.BeaconsTest do + use Microwaveprop.DataCase + + import Microwaveprop.AccountsFixtures + import Microwaveprop.BeaconsFixtures + + alias Microwaveprop.Beacons + alias Microwaveprop.Beacons.Beacon + + @invalid_attrs %{ + frequency_mhz: nil, + callsign: nil, + grid: nil, + lat: nil, + lon: nil, + power_watts: nil, + height_m: nil + } + + describe "list_beacons/0" do + test "returns all beacons regardless of creator" do + u1 = user_fixture() + u2 = user_fixture() + b1 = beacon_fixture(u1) + b2 = beacon_fixture(u2, callsign: "W5TX") + assert [^b1, ^b2] = Enum.sort_by(Beacons.list_beacons(), & &1.callsign) + end + end + + describe "get_beacon!/1" do + test "returns the beacon with given id" do + user = user_fixture() + beacon = beacon_fixture(user) + assert Beacons.get_beacon!(beacon.id).id == beacon.id + end + end + + describe "create_beacon/2" do + test "with valid data creates a beacon and records the creator" do + user = user_fixture() + attrs = valid_beacon_attrs() + + assert {:ok, %Beacon{} = beacon} = Beacons.create_beacon(user, attrs) + assert beacon.frequency_mhz == attrs.frequency_mhz + assert beacon.callsign == "W5HN" + assert beacon.lat == attrs.lat + assert beacon.lon == attrs.lon + assert beacon.power_watts == attrs.power_watts + assert beacon.height_m == attrs.height_m + assert beacon.user_id == user.id + end + + test "derives grid from lat/lon when grid is omitted" do + user = user_fixture() + # DFW is EM12 + {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(lat: 32.897, lon: -97.038)) + assert String.starts_with?(beacon.grid, "EM12") + end + + test "keeps an explicitly provided valid grid" do + user = user_fixture() + {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(grid: "EM13")) + assert beacon.grid == "EM13" + end + + test "rejects an invalid grid" do + user = user_fixture() + + assert {:error, changeset} = + Beacons.create_beacon(user, valid_beacon_attrs(grid: "ZZ99")) + + assert "is not a valid Maidenhead grid" in errors_on(changeset).grid + end + + test "upcases the callsign" do + user = user_fixture() + {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(callsign: "w5hn")) + assert beacon.callsign == "W5HN" + end + + test "rejects non-positive frequency" do + user = user_fixture() + + assert {:error, changeset} = + Beacons.create_beacon(user, valid_beacon_attrs(frequency_mhz: 0)) + + assert "must be greater than 0" in errors_on(changeset).frequency_mhz + end + + test "with invalid data returns error changeset" do + user = user_fixture() + assert {:error, %Ecto.Changeset{}} = Beacons.create_beacon(user, @invalid_attrs) + end + end + + describe "update_beacon/2" do + test "with valid data updates the beacon" do + user = user_fixture() + beacon = beacon_fixture(user) + + update_attrs = %{frequency_mhz: 24_192.1, power_watts: 5.0} + + assert {:ok, %Beacon{} = beacon} = Beacons.update_beacon(beacon, update_attrs) + assert beacon.frequency_mhz == 24_192.1 + assert beacon.power_watts == 5.0 + end + + test "with invalid data returns error changeset" do + user = user_fixture() + beacon = beacon_fixture(user) + assert {:error, %Ecto.Changeset{}} = Beacons.update_beacon(beacon, @invalid_attrs) + assert Beacons.get_beacon!(beacon.id).callsign == beacon.callsign + end + end + + describe "delete_beacon/1" do + test "deletes the beacon" do + user = user_fixture() + beacon = beacon_fixture(user) + assert {:ok, %Beacon{}} = Beacons.delete_beacon(beacon) + assert_raise Ecto.NoResultsError, fn -> Beacons.get_beacon!(beacon.id) end + end + end + + describe "change_beacon/1" do + test "returns a beacon changeset" do + user = user_fixture() + beacon = beacon_fixture(user) + assert %Ecto.Changeset{} = Beacons.change_beacon(beacon) + end + end +end diff --git a/test/microwaveprop/radio/maidenhead_test.exs b/test/microwaveprop/radio/maidenhead_test.exs index 96238117..0e69b530 100644 --- a/test/microwaveprop/radio/maidenhead_test.exs +++ b/test/microwaveprop/radio/maidenhead_test.exs @@ -104,4 +104,32 @@ defmodule Microwaveprop.Radio.MaidenheadTest do assert_in_delta lon, 179.0, 0.01 end end + + describe "from_latlon/3" do + test "round-trips 4-char grid FN31" do + {:ok, {lat, lon}} = Maidenhead.to_latlon("FN31") + assert Maidenhead.from_latlon(lat, lon, 4) == "FN31" + end + + test "round-trips 6-char grid FN31pr" do + {:ok, {lat, lon}} = Maidenhead.to_latlon("FN31pr") + assert Maidenhead.from_latlon(lat, lon, 6) == "FN31pr" + end + + test "defaults to precision 6" do + {:ok, {lat, lon}} = Maidenhead.to_latlon("EM12kp") + assert Maidenhead.from_latlon(lat, lon) == "EM12kp" + end + + test "round-trips 8-char grid EM12kp37" do + {:ok, {lat, lon}} = Maidenhead.to_latlon("EM12kp37") + assert Maidenhead.from_latlon(lat, lon, 8) == "EM12kp37" + end + + test "handles North Texas coordinates" do + # DFW area is approximately EM12/EM13 + grid = Maidenhead.from_latlon(32.897, -97.038, 6) + assert String.starts_with?(grid, "EM12") + end + end end diff --git a/test/microwaveprop_web/live/beacon_live_test.exs b/test/microwaveprop_web/live/beacon_live_test.exs new file mode 100644 index 00000000..3d27dbf8 --- /dev/null +++ b/test/microwaveprop_web/live/beacon_live_test.exs @@ -0,0 +1,94 @@ +defmodule MicrowavepropWeb.BeaconLiveTest do + use MicrowavepropWeb.ConnCase + + import Microwaveprop.AccountsFixtures + import Microwaveprop.BeaconsFixtures + import Phoenix.LiveViewTest + + alias Microwaveprop.Accounts + + defp promote_to_admin(user) do + {:ok, user} = Accounts.admin_update_user(user, %{is_admin: true}) + user + end + + defp admin_user_fixture do + promote_to_admin(user_fixture()) + end + + describe "Index (public)" do + test "lists beacons without logging in", %{conn: conn} do + beacon = beacon_fixture(user_fixture()) + {:ok, _view, html} = live(conn, ~p"/beacons") + assert html =~ "Beacons" + assert html =~ beacon.callsign + end + + test "non-admin does not see the New Beacon button", %{conn: conn} do + user = user_fixture() + conn = log_in_user(conn, user) + {:ok, _view, html} = live(conn, ~p"/beacons") + refute html =~ "New Beacon" + end + + test "admin sees the New Beacon button", %{conn: conn} do + conn = log_in_user(conn, admin_user_fixture()) + {:ok, _view, html} = live(conn, ~p"/beacons") + assert html =~ "New Beacon" + end + end + + describe "Show (public)" do + test "renders beacon details without logging in", %{conn: conn} do + beacon = beacon_fixture(user_fixture()) + {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") + assert html =~ beacon.callsign + assert html =~ "#{beacon.frequency_mhz}" + end + end + + describe "Form (admin only)" do + test "redirects non-admin away from /beacons/new", %{conn: conn} do + user = user_fixture() + conn = log_in_user(conn, user) + + assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/beacons/new") + end + + test "redirects anonymous user away from /beacons/new", %{conn: conn} do + assert {:error, {:redirect, %{to: "/users/log-in"}}} = live(conn, ~p"/beacons/new") + end + + test "admin creates a beacon", %{conn: conn} do + conn = log_in_user(conn, admin_user_fixture()) + {:ok, form_live, _html} = live(conn, ~p"/beacons/new") + + attrs = valid_beacon_attrs() + + assert {:ok, _view, html} = + form_live + |> form("#beacon-form", beacon: attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/beacons") + + assert html =~ "Beacon created" + assert html =~ attrs.callsign + end + + test "admin edits a beacon", %{conn: conn} do + admin = admin_user_fixture() + beacon = beacon_fixture(admin) + conn = log_in_user(conn, admin) + + {:ok, form_live, _} = live(conn, ~p"/beacons/#{beacon}/edit") + + assert {:ok, _view, html} = + form_live + |> form("#beacon-form", beacon: %{power_watts: 25.0}) + |> render_submit() + |> follow_redirect(conn, ~p"/beacons") + + assert html =~ "Beacon updated" + 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 new file mode 100644 index 00000000..d234b676 --- /dev/null +++ b/test/microwaveprop_web/live/user_management_live_test.exs @@ -0,0 +1,71 @@ +defmodule MicrowavepropWeb.UserManagementLiveTest do + use MicrowavepropWeb.ConnCase + + import Microwaveprop.AccountsFixtures + import Phoenix.LiveViewTest + + alias Microwaveprop.Accounts + + defp admin_user_fixture do + user = user_fixture() + {:ok, user} = Accounts.admin_update_user(user, %{is_admin: true}) + user + end + + describe "Index" do + test "redirects non-admin to /", %{conn: conn} do + conn = log_in_user(conn, user_fixture()) + assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/users") + end + + test "redirects anonymous user to log in", %{conn: conn} do + assert {:error, {:redirect, %{to: "/users/log-in"}}} = live(conn, ~p"/users") + end + + test "admin sees the user list", %{conn: conn} do + admin = admin_user_fixture() + other = user_fixture(callsign: "W5TX") + conn = log_in_user(conn, admin) + + {:ok, _view, html} = live(conn, ~p"/users") + assert html =~ admin.callsign + assert html =~ other.callsign + end + + test "admin can delete another user", %{conn: conn} do + admin = admin_user_fixture() + target = user_fixture(callsign: "W5TX") + conn = log_in_user(conn, admin) + + {:ok, view, _} = live(conn, ~p"/users") + view |> element("tr##{"users-" <> target.id} a", "Delete") |> render_click() + + refute Accounts.get_user_by_email(target.email) + end + end + + describe "Edit" do + test "admin can promote a user to admin", %{conn: conn} do + admin = admin_user_fixture() + target = user_fixture(callsign: "W5TX") + conn = log_in_user(conn, admin) + + {:ok, form_live, _} = live(conn, ~p"/users/#{target.id}/edit") + + assert {:ok, _view, _html} = + form_live + |> form("#user-form", + user: %{ + callsign: target.callsign, + name: target.name, + email: target.email, + is_admin: true + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/users") + + assert Accounts.get_user!(target.id).is_admin + end + end +end diff --git a/test/support/fixtures/beacons_fixtures.ex b/test/support/fixtures/beacons_fixtures.ex new file mode 100644 index 00000000..131f7f04 --- /dev/null +++ b/test/support/fixtures/beacons_fixtures.ex @@ -0,0 +1,23 @@ +defmodule Microwaveprop.BeaconsFixtures do + @moduledoc """ + Test helpers for creating beacon records. + """ + + alias Microwaveprop.Beacons + + def valid_beacon_attrs(attrs \\ %{}) do + Enum.into(attrs, %{ + frequency_mhz: 10_368.1, + callsign: "W5HN", + lat: 32.897, + lon: -97.038, + power_watts: 10.0, + height_m: 30.0 + }) + end + + def beacon_fixture(user, attrs \\ %{}) do + {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(attrs)) + beacon + end +end