diff --git a/docs/api/README.md b/docs/api/README.md index 2376fb0d..5385d3a0 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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. diff --git a/lib/microwaveprop/markdown.ex b/lib/microwaveprop/markdown.ex index c8e4436e..b5e11ba3 100644 --- a/lib/microwaveprop/markdown.ex +++ b/lib/microwaveprop/markdown.ex @@ -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} diff --git a/lib/microwaveprop_web/controllers/api_token_controller.ex b/lib/microwaveprop_web/controllers/api_token_controller.ex new file mode 100644 index 00000000..ecdcafe0 --- /dev/null +++ b/lib/microwaveprop_web/controllers/api_token_controller.ex @@ -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 diff --git a/lib/microwaveprop_web/controllers/user_settings_controller.ex b/lib/microwaveprop_web/controllers/user_settings_controller.ex index ca621336..718483d4 100644 --- a/lib/microwaveprop_web/controllers/user_settings_controller.ex +++ b/lib/microwaveprop_web/controllers/user_settings_controller.ex @@ -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 diff --git a/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex b/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex index e7758ecb..7e608547 100644 --- a/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex +++ b/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex @@ -169,4 +169,105 @@ <% end %> + +
+ +
+ <.header> + API tokens + <:subtitle> + Long-lived bearer tokens for the + <.link navigate={~p"/docs/api"} class="link">REST API + at /api/v1. Each token grants the same + permissions as your account; treat them like passwords. + + + + <%= if Phoenix.Flash.get(@flash, :api_token) do %> +
+ <.icon name="hero-key" class="size-4 shrink-0" /> +
+

+ New token — copy now, it will not be shown again: +

+ + {Phoenix.Flash.get(@flash, :api_token)} + +
+
+ <% end %> + + <.form + :let={f} + for={%{}} + as={:api_token} + action={~p"/users/api-tokens"} + id="create_api_token" + > + +
+ + <.button variant="primary" phx-disable-with="Creating...">Create token +
+ + + <%= if @api_tokens == [] do %> +

No API tokens yet.

+ <% else %> +
+ + + + + + + + + + + + <%= for token <- @api_tokens do %> + + + + + + + + <% end %> + +
NameCreatedLast usedExpires
{token.name} + {Calendar.strftime(token.inserted_at, "%Y-%m-%d")} + + <%= if token.last_used_at do %> + {Calendar.strftime(token.last_used_at, "%Y-%m-%d %H:%M UTC")} + <% else %> + never + <% end %> + + <%= if token.expires_at do %> + {Calendar.strftime(token.expires_at, "%Y-%m-%d")} + <% else %> + never + <% end %> + + <.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 + +
+
+ <% end %> +
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 1f341ae3..2ac2f2dc 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -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 diff --git a/test/microwaveprop_web/controllers/api_token_controller_test.exs b/test/microwaveprop_web/controllers/api_token_controller_test.exs new file mode 100644 index 00000000..5d731d08 --- /dev/null +++ b/test/microwaveprop_web/controllers/api_token_controller_test.exs @@ -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