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:
parent
b18f93be7f
commit
04f9ed5fe6
7 changed files with 231 additions and 2 deletions
|
|
@ -14,7 +14,7 @@ approval, contact moderation) are deliberately excluded from this API.
|
||||||
user's password to API clients.
|
user's password to API clients.
|
||||||
* **Format:** `application/json` for requests and successful responses;
|
* **Format:** `application/json` for requests and successful responses;
|
||||||
errors use [RFC 9457 problem+json](https://www.rfc-editor.org/rfc/rfc9457).
|
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
|
## Quickstart
|
||||||
|
|
||||||
|
|
@ -264,6 +264,6 @@ the user. Email is **not** exposed.
|
||||||
|
|
||||||
## See also
|
## 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).
|
* `/.well-known/api-catalog` — RFC 9727 service descriptor (public).
|
||||||
* `/algo` — the scoring algorithm in detail.
|
* `/algo` — the scoring algorithm in detail.
|
||||||
|
|
|
||||||
|
|
@ -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(["#" <> _ | _] = 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
|
defp take_paragraph([line | _] = rest, acc) when binary_part(line, 0, min(byte_size(line), 2)) in ["- ", "* ", "+ "] do
|
||||||
{Enum.reverse(acc), rest}
|
{Enum.reverse(acc), rest}
|
||||||
end
|
end
|
||||||
|
|
||||||
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(["---" | _] = rest, acc), do: {Enum.reverse(acc), rest}
|
||||||
|
|
||||||
|
|
|
||||||
46
lib/microwaveprop_web/controllers/api_token_controller.ex
Normal file
46
lib/microwaveprop_web/controllers/api_token_controller.ex
Normal 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
|
||||||
|
|
@ -11,6 +11,7 @@ defmodule MicrowavepropWeb.UserSettingsController do
|
||||||
plug :assign_email_and_password_changesets
|
plug :assign_email_and_password_changesets
|
||||||
plug :assign_home_qth_changeset
|
plug :assign_home_qth_changeset
|
||||||
plug :assign_beacon_monitors
|
plug :assign_beacon_monitors
|
||||||
|
plug :assign_api_tokens
|
||||||
|
|
||||||
def edit(conn, _params) do
|
def edit(conn, _params) do
|
||||||
render(conn, :edit)
|
render(conn, :edit)
|
||||||
|
|
@ -106,4 +107,9 @@ defmodule MicrowavepropWeb.UserSettingsController do
|
||||||
|> assign(:beacon_monitors, BeaconMonitors.list_monitors_for_user(user))
|
|> assign(:beacon_monitors, BeaconMonitors.list_monitors_for_user(user))
|
||||||
|> assign(:beacon_monitor_changeset, BeaconMonitors.change_monitor())
|
|> assign(:beacon_monitor_changeset, BeaconMonitors.change_monitor())
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -169,4 +169,105 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</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>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,9 @@ defmodule MicrowavepropWeb.Router do
|
||||||
|
|
||||||
post "/users/beacon-monitors", BeaconMonitorController, :create
|
post "/users/beacon-monitors", BeaconMonitorController, :create
|
||||||
delete "/users/beacon-monitors/:id", BeaconMonitorController, :delete
|
delete "/users/beacon-monitors/:id", BeaconMonitorController, :delete
|
||||||
|
|
||||||
|
post "/users/api-tokens", ApiTokenController, :create
|
||||||
|
delete "/users/api-tokens/:id", ApiTokenController, :delete
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/", MicrowavepropWeb do
|
scope "/", MicrowavepropWeb do
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue