feat(settings): manage /api/v1 tokens from user settings UI

Adds a separate API-token section under /users/settings (distinct from
the beacon-monitor token list, since API tokens grant full account
access). The plaintext is surfaced exactly once via flash on creation;
only the SHA-256 hash is persisted, so revocation is the only path back
if the user loses it.

Also fixes the openapi.yaml link on /docs/api: the relative path
resolved to /docs/openapi.yaml from a no-trailing-slash URL and 404'd.
This commit is contained in:
Graham McIntire 2026-05-09 09:59:56 -05:00
parent b18f93be7f
commit 04f9ed5fe6
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
7 changed files with 231 additions and 2 deletions

View file

@ -14,7 +14,7 @@ approval, contact moderation) are deliberately excluded from this API.
user's password to API clients.
* **Format:** `application/json` for requests and successful responses;
errors use [RFC 9457 problem+json](https://www.rfc-editor.org/rfc/rfc9457).
* **OpenAPI 3.1 spec:** [`openapi.yaml`](./openapi.yaml).
* **OpenAPI 3.1 spec:** [`openapi.yaml`](/docs/api/openapi.yaml).
## Quickstart
@ -264,6 +264,6 @@ the user. Email is **not** exposed.
## See also
* [`openapi.yaml`](./openapi.yaml) — the machine-readable spec.
* [`openapi.yaml`](/docs/api/openapi.yaml) — the machine-readable spec.
* `/.well-known/api-catalog` — RFC 9727 service descriptor (public).
* `/algo` — the scoring algorithm in detail.

View file

@ -125,9 +125,11 @@ defmodule Microwaveprop.Markdown do
defp take_paragraph(["" | rest], acc), do: {Enum.reverse(acc), rest}
defp take_paragraph(["#" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest}
defp take_paragraph(["|" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest}
defp take_paragraph([line | _] = rest, acc) when binary_part(line, 0, min(byte_size(line), 2)) in ["- ", "* ", "+ "] do
{Enum.reverse(acc), rest}
end
defp take_paragraph(["```" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest}
defp take_paragraph(["---" | _] = rest, acc), do: {Enum.reverse(acc), rest}

View file

@ -0,0 +1,46 @@
defmodule MicrowavepropWeb.ApiTokenController do
@moduledoc """
User-facing CRUD for long-lived `/api/v1` bearer tokens. The plaintext
token is surfaced exactly once via flash on creation; only the SHA-256
hash is persisted, so revocation is the only path back if the user
loses it.
"""
use MicrowavepropWeb, :controller
alias Microwaveprop.Accounts
def create(conn, %{"api_token" => params}) do
user = conn.assigns.current_scope.user
case Accounts.create_api_token(user, params) do
{:ok, {plaintext, record}} ->
conn
|> put_flash(:info, "API token '#{record.name}' created. Copy it now — it is shown once.")
|> put_flash(:api_token, plaintext)
|> 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 API token (#{message}).")
|> redirect(to: ~p"/users/settings")
end
end
def delete(conn, %{"id" => id}) do
user = conn.assigns.current_scope.user
conn =
case Accounts.revoke_api_token(user, id) do
{:ok, _token} -> put_flash(conn, :info, "API token revoked.")
{:error, :not_found} -> put_flash(conn, :error, "API token not found.")
end
redirect(conn, to: ~p"/users/settings")
end
end

View file

@ -11,6 +11,7 @@ defmodule MicrowavepropWeb.UserSettingsController do
plug :assign_email_and_password_changesets
plug :assign_home_qth_changeset
plug :assign_beacon_monitors
plug :assign_api_tokens
def edit(conn, _params) do
render(conn, :edit)
@ -106,4 +107,9 @@ defmodule MicrowavepropWeb.UserSettingsController do
|> assign(:beacon_monitors, BeaconMonitors.list_monitors_for_user(user))
|> assign(:beacon_monitor_changeset, BeaconMonitors.change_monitor())
end
defp assign_api_tokens(conn, _opts) do
user = conn.assigns.current_scope.user
assign(conn, :api_tokens, Accounts.list_api_tokens(user))
end
end

View file

@ -169,4 +169,105 @@
</div>
<% end %>
</section>
<div class="divider" />
<section id="api-tokens" class="space-y-4">
<.header>
API tokens
<:subtitle>
Long-lived bearer tokens for the
<.link navigate={~p"/docs/api"} class="link">REST API</.link>
at <code>/api/v1</code>. Each token grants the same
permissions as your account; treat them like passwords.
</:subtitle>
</.header>
<%= if Phoenix.Flash.get(@flash, :api_token) do %>
<div class="alert alert-success text-sm">
<.icon name="hero-key" class="size-4 shrink-0" />
<div class="flex-1">
<p class="font-semibold mb-1">
New token — copy now, it will not be shown again:
</p>
<code class="text-xs break-all select-all block bg-base-200 rounded p-2">
{Phoenix.Flash.get(@flash, :api_token)}
</code>
</div>
</div>
<% end %>
<.form
:let={f}
for={%{}}
as={:api_token}
action={~p"/users/api-tokens"}
id="create_api_token"
>
<label for={f[:name].id} class="label mb-1">Token name</label>
<div class="flex items-center gap-2">
<input
type="text"
name={f[:name].name}
id={f[:name].id}
placeholder="e.g. laptop, scripts, monitor box"
class="input flex-1"
required
/>
<.button variant="primary" phx-disable-with="Creating...">Create token</.button>
</div>
</.form>
<%= if @api_tokens == [] do %>
<p class="text-sm opacity-70">No API tokens yet.</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Last used</th>
<th>Expires</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
<%= for token <- @api_tokens do %>
<tr>
<td class="font-semibold">{token.name}</td>
<td class="text-sm opacity-70">
{Calendar.strftime(token.inserted_at, "%Y-%m-%d")}
</td>
<td class="text-sm opacity-70">
<%= if token.last_used_at do %>
{Calendar.strftime(token.last_used_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</td>
<td class="text-sm opacity-70">
<%= if token.expires_at do %>
{Calendar.strftime(token.expires_at, "%Y-%m-%d")}
<% else %>
never
<% end %>
</td>
<td>
<.link
href={~p"/users/api-tokens/#{token.id}"}
method="delete"
class="btn btn-ghost btn-xs text-error"
data-confirm={"Revoke API token '#{token.name}'? It will stop working immediately."}
>
Revoke
</.link>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</section>
</Layouts.app>

View file

@ -341,6 +341,9 @@ defmodule MicrowavepropWeb.Router do
post "/users/beacon-monitors", BeaconMonitorController, :create
delete "/users/beacon-monitors/:id", BeaconMonitorController, :delete
post "/users/api-tokens", ApiTokenController, :create
delete "/users/api-tokens/:id", ApiTokenController, :delete
end
scope "/", MicrowavepropWeb do

View file

@ -0,0 +1,71 @@
defmodule MicrowavepropWeb.ApiTokenControllerTest do
use MicrowavepropWeb.ConnCase, async: true
import Microwaveprop.AccountsFixtures
alias Microwaveprop.Accounts
setup :register_and_log_in_user
describe "POST /users/api-tokens" do
test "creates a token and surfaces the plaintext once via flash", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/api-tokens", %{
"api_token" => %{"name" => "laptop"}
})
assert redirected_to(conn) == ~p"/users/settings"
info = Phoenix.Flash.get(conn.assigns.flash, :info)
assert info =~ "laptop"
token = Phoenix.Flash.get(conn.assigns.flash, :api_token)
assert is_binary(token)
assert String.starts_with?(token, "mwp_")
assert [record] = Accounts.list_api_tokens(user)
assert record.name == "laptop"
end
test "shows an error when name is blank", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/api-tokens", %{
"api_token" => %{"name" => ""}
})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "name"
assert Accounts.list_api_tokens(user) == []
end
test "redirects unauthenticated users to login" do
conn =
post(build_conn(), ~p"/users/api-tokens", %{
"api_token" => %{"name" => "Nope"}
})
assert redirected_to(conn) == ~p"/users/log-in"
end
end
describe "DELETE /users/api-tokens/:id" do
test "revokes a token owned by the current user", %{conn: conn, user: user} do
{:ok, {_plaintext, record}} = Accounts.create_api_token(user, %{"name" => "bye"})
conn = delete(conn, ~p"/users/api-tokens/#{record.id}")
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "revoked"
assert Accounts.list_api_tokens(user) == []
end
test "does not revoke a token owned by another user", %{conn: conn} do
other = user_fixture()
{:ok, {_plaintext, record}} = Accounts.create_api_token(other, %{"name" => "theirs"})
conn = delete(conn, ~p"/users/api-tokens/#{record.id}")
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "not found"
assert [_] = Accounts.list_api_tokens(other)
end
end
end