Add beacon monitor registration and fix prod SMTP

Users can now register beacon monitors on the settings page. Each
monitor gets a unique random token the remote program will use to
authenticate its reports. Name is the only user-supplied field for
now; more will be added as the monitor protocol is defined.

Also adds :gen_smtp to deps. Swoosh's SMTP adapter depends on
:mimemail which lives in that package, and production was 500'ing
when trying to send confirmation emails without it.
This commit is contained in:
Graham McIntire 2026-04-08 11:29:50 -05:00
parent 50d4c0b52f
commit 355bed178d
11 changed files with 422 additions and 0 deletions

View file

@ -0,0 +1,74 @@
defmodule Microwaveprop.BeaconMonitors do
@moduledoc """
The BeaconMonitors context: manages the monitor stations a user
has registered. Each monitor has a unique random token the remote
program uses to authenticate its reports.
"""
import Ecto.Query
alias Microwaveprop.Accounts.User
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Repo
@token_bytes 32
@doc """
Returns all monitors for the given user, newest first.
"""
def list_monitors_for_user(%User{id: user_id}) do
Repo.all(
from m in BeaconMonitor,
where: m.user_id == ^user_id,
order_by: [desc: m.inserted_at]
)
end
@doc """
Creates a new monitor for the given user with a freshly generated token.
"""
def create_monitor(%User{} = user, attrs) do
%BeaconMonitor{user_id: user.id, token: generate_token()}
|> BeaconMonitor.changeset(attrs)
|> Repo.insert()
end
@doc """
Deletes a monitor owned by the given user.
Returns `{:error, :not_found}` if the monitor does not exist or
belongs to another user.
"""
def delete_monitor(%User{id: user_id}, monitor_id) do
query =
from m in BeaconMonitor,
where: m.id == ^monitor_id and m.user_id == ^user_id
case Repo.one(query) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
end
rescue
Ecto.Query.CastError -> {:error, :not_found}
end
@doc """
Looks up a monitor by its token. Returns nil if not found.
"""
def get_monitor_by_token(token) when is_binary(token) do
Repo.get_by(BeaconMonitor, token: token)
end
@doc """
Returns a blank changeset for rendering the new-monitor form.
"""
def change_monitor(attrs \\ %{}) do
BeaconMonitor.changeset(%BeaconMonitor{}, attrs)
end
defp generate_token do
@token_bytes
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
end
end

View file

@ -0,0 +1,36 @@
defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
@moduledoc """
A beacon monitor is a remote station running the monitor program
that reports beacon reception data. Each monitor is owned by a user
and identified by a random token the program uses to authenticate.
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "beacon_monitors" do
field :name, :string
field :token, :string
field :last_seen_at, :utc_datetime
belongs_to :user, User
timestamps(type: :utc_datetime)
end
@doc """
Changeset for user-controlled fields (name only for now).
Token and user_id are assigned by the context, not cast from user input.
"""
def changeset(monitor, attrs) do
monitor
|> cast(attrs, [:name])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
end
end

View file

@ -0,0 +1,38 @@
defmodule MicrowavepropWeb.BeaconMonitorController do
use MicrowavepropWeb, :controller
alias Microwaveprop.BeaconMonitors
def create(conn, %{"beacon_monitor" => params}) do
user = conn.assigns.current_scope.user
case BeaconMonitors.create_monitor(user, params) do
{:ok, monitor} ->
conn
|> put_flash(:info, "Monitor '#{monitor.name}' created. Copy its token below.")
|> redirect(to: ~p"/users/settings")
{:error, changeset} ->
message =
changeset
|> Ecto.Changeset.traverse_errors(fn {msg, _} -> msg end)
|> Enum.map_join("; ", fn {k, v} -> "#{k}: #{Enum.join(v, ", ")}" end)
conn
|> put_flash(:error, "Could not create monitor (#{message}).")
|> redirect(to: ~p"/users/settings")
end
end
def delete(conn, %{"id" => id}) do
user = conn.assigns.current_scope.user
conn =
case BeaconMonitors.delete_monitor(user, id) do
{:ok, _monitor} -> put_flash(conn, :info, "Monitor deleted.")
{:error, :not_found} -> put_flash(conn, :error, "Monitor not found.")
end
redirect(conn, to: ~p"/users/settings")
end
end

View file

@ -4,10 +4,12 @@ defmodule MicrowavepropWeb.UserSettingsController do
import MicrowavepropWeb.UserAuth, only: [require_sudo_mode: 2]
alias Microwaveprop.Accounts
alias Microwaveprop.BeaconMonitors
alias MicrowavepropWeb.UserAuth
plug :require_sudo_mode
plug :assign_email_and_password_changesets
plug :assign_beacon_monitors
def edit(conn, _params) do
render(conn, :edit)
@ -74,4 +76,12 @@ defmodule MicrowavepropWeb.UserSettingsController do
|> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user))
end
defp assign_beacon_monitors(conn, _opts) do
user = conn.assigns.current_scope.user
conn
|> assign(:beacon_monitors, BeaconMonitors.list_monitors_for_user(user))
|> assign(:beacon_monitor_changeset, BeaconMonitors.change_monitor())
end
end

View file

@ -46,4 +46,80 @@
Save Password
</.button>
</.form>
<div class="divider" />
<section id="beacon-monitors" class="space-y-4">
<.header>
Beacon monitors
<:subtitle>
Register remote monitor stations. Each monitor gets a unique token the
monitor program uses to authenticate its reports.
</:subtitle>
</.header>
<.form
:let={f}
for={@beacon_monitor_changeset}
as={:beacon_monitor}
action={~p"/users/beacon-monitors"}
id="create_beacon_monitor"
class="flex items-end gap-2"
>
<div class="flex-1">
<.input
field={f[:name]}
type="text"
label="Monitor name"
placeholder="e.g. Shack Pi"
required
/>
</div>
<.button variant="primary" phx-disable-with="Adding...">Add monitor</.button>
</.form>
<%= if @beacon_monitors == [] do %>
<p class="text-sm opacity-70">No monitors registered yet.</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Token</th>
<th>Last seen</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
<%= for monitor <- @beacon_monitors do %>
<tr>
<td class="font-semibold">{monitor.name}</td>
<td>
<code class="text-xs break-all select-all">{monitor.token}</code>
</td>
<td class="text-sm opacity-70">
<%= if monitor.last_seen_at do %>
{Calendar.strftime(monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</td>
<td>
<.link
href={~p"/users/beacon-monitors/#{monitor.id}"}
method="delete"
class="btn btn-ghost btn-xs text-error"
data-confirm={"Delete monitor '#{monitor.name}'? Its token will stop working immediately."}
>
Delete
</.link>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</section>
</Layouts.app>

View file

@ -86,6 +86,9 @@ defmodule MicrowavepropWeb.Router do
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
post "/users/beacon-monitors", BeaconMonitorController, :create
delete "/users/beacon-monitors/:id", BeaconMonitorController, :delete
end
scope "/", MicrowavepropWeb do

View file

@ -58,6 +58,7 @@ defmodule Microwaveprop.MixProject do
{:heroicons,
github: "tailwindlabs/heroicons", tag: "v2.2.0", sparse: "optimized", app: false, compile: false, depth: 1},
{:swoosh, "~> 1.16"},
{:gen_smtp, "~> 1.2"},
{:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},

View file

@ -20,6 +20,7 @@
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
@ -46,6 +47,7 @@
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"polaris": {:hex, :polaris, "0.1.0", "dca61b18e3e801ecdae6ac9f0eca5f19792b44a5cb4b8d63db50fc40fc038d22", [:mix], [{:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "13ef2b166650e533cb24b10e2f3b8ab4f2f449ba4d63156e8c569527f206e2c2"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"},

View file

@ -0,0 +1,18 @@
defmodule Microwaveprop.Repo.Migrations.CreateBeaconMonitors do
use Ecto.Migration
def change do
create table(:beacon_monitors, primary_key: false) do
add :id, :binary_id, primary_key: true
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
add :name, :string, null: false
add :token, :string, null: false
add :last_seen_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create index(:beacon_monitors, [:user_id])
create unique_index(:beacon_monitors, [:token])
end
end

View file

@ -0,0 +1,99 @@
defmodule Microwaveprop.BeaconMonitorsTest do
use Microwaveprop.DataCase
import Microwaveprop.AccountsFixtures
alias Microwaveprop.BeaconMonitors
alias Microwaveprop.BeaconMonitors.BeaconMonitor
describe "create_monitor/2" do
test "creates a monitor with a generated token" do
user = user_fixture()
assert {:ok, %BeaconMonitor{} = monitor} =
BeaconMonitors.create_monitor(user, %{"name" => "Shack Pi"})
assert monitor.user_id == user.id
assert monitor.name == "Shack Pi"
assert is_binary(monitor.token)
# strong_rand_bytes(32) |> url_encode64(padding: false) is 43 chars
assert String.length(monitor.token) == 43
end
test "generates a unique token per monitor" do
user = user_fixture()
{:ok, m1} = BeaconMonitors.create_monitor(user, %{"name" => "One"})
{:ok, m2} = BeaconMonitors.create_monitor(user, %{"name" => "Two"})
refute m1.token == m2.token
end
test "requires a name" do
user = user_fixture()
assert {:error, changeset} = BeaconMonitors.create_monitor(user, %{"name" => ""})
assert %{name: ["can't be blank"]} = errors_on(changeset)
end
end
describe "list_monitors_for_user/1" do
test "returns monitors belonging to the user, newest first" do
user = user_fixture()
other = user_fixture()
{:ok, first} = BeaconMonitors.create_monitor(user, %{"name" => "First"})
Process.sleep(1100)
{:ok, second} = BeaconMonitors.create_monitor(user, %{"name" => "Second"})
{:ok, _other} = BeaconMonitors.create_monitor(other, %{"name" => "Nope"})
assert [second_result, first_result] = BeaconMonitors.list_monitors_for_user(user)
assert second_result.id == second.id
assert first_result.id == first.id
end
test "returns empty list when user has no monitors" do
user = user_fixture()
assert BeaconMonitors.list_monitors_for_user(user) == []
end
end
describe "delete_monitor/2" do
test "deletes a monitor owned by the user" do
user = user_fixture()
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Bye"})
assert {:ok, %BeaconMonitor{}} = BeaconMonitors.delete_monitor(user, monitor.id)
assert BeaconMonitors.list_monitors_for_user(user) == []
end
test "does not delete a monitor owned by another user" do
user = user_fixture()
other = user_fixture()
{:ok, monitor} = BeaconMonitors.create_monitor(other, %{"name" => "Theirs"})
assert {:error, :not_found} = BeaconMonitors.delete_monitor(user, monitor.id)
assert [_] = BeaconMonitors.list_monitors_for_user(other)
end
test "returns :not_found for nonexistent id" do
user = user_fixture()
assert {:error, :not_found} =
BeaconMonitors.delete_monitor(user, "11111111-1111-1111-1111-111111111111")
end
end
describe "get_monitor_by_token/1" do
test "returns the monitor matching the token" do
user = user_fixture()
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Find Me"})
found = BeaconMonitors.get_monitor_by_token(monitor.token)
assert found.id == monitor.id
end
test "returns nil for unknown token" do
refute BeaconMonitors.get_monitor_by_token("not-a-real-token")
end
end
end

View file

@ -0,0 +1,65 @@
defmodule MicrowavepropWeb.BeaconMonitorControllerTest do
use MicrowavepropWeb.ConnCase, async: true
import Microwaveprop.AccountsFixtures
alias Microwaveprop.BeaconMonitors
setup :register_and_log_in_user
describe "POST /users/beacon-monitors" do
test "creates a monitor for the current user", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/beacon-monitors", %{
"beacon_monitor" => %{"name" => "Shack Pi"}
})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Shack Pi"
assert [monitor] = BeaconMonitors.list_monitors_for_user(user)
assert monitor.name == "Shack Pi"
assert is_binary(monitor.token)
end
test "shows an error when name is blank", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/beacon-monitors", %{
"beacon_monitor" => %{"name" => ""}
})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "name"
assert BeaconMonitors.list_monitors_for_user(user) == []
end
test "redirects unauthenticated users to login" do
conn = post(build_conn(), ~p"/users/beacon-monitors", %{"beacon_monitor" => %{"name" => "Nope"}})
assert redirected_to(conn) == ~p"/users/log-in"
end
end
describe "DELETE /users/beacon-monitors/:id" do
test "deletes a monitor owned by the current user", %{conn: conn, user: user} do
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Bye"})
conn = delete(conn, ~p"/users/beacon-monitors/#{monitor.id}")
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "deleted"
assert BeaconMonitors.list_monitors_for_user(user) == []
end
test "does not delete a monitor owned by another user", %{conn: conn} do
other = user_fixture()
{:ok, monitor} = BeaconMonitors.create_monitor(other, %{"name" => "Theirs"})
conn = delete(conn, ~p"/users/beacon-monitors/#{monitor.id}")
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "not found"
assert [_] = BeaconMonitors.list_monitors_for_user(other)
end
end
end