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 %> + +
+ +/api/v1. Each token grants the same
+ permissions as your account; treat them like passwords.
+
+
+
+ <%= if Phoenix.Flash.get(@flash, :api_token) do %>
+ + New token — copy now, it will not be shown again: +
+
+ {Phoenix.Flash.get(@flash, :api_token)}
+
+ No API tokens yet.
+ <% else %> +| Name | +Created | +Last used | +Expires | ++ |
|---|---|---|---|---|
| {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 + + | +