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.
|
||||
* **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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
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_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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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