neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Android client phase 1a: connected devices and pairing QR
92c48b1 · Gabriel Morell · 2026-09-14 22:06
Message
{commit_body(@commit)}
Files changed
modified
docs/android.md
+17
−3
@@ -477,9 +477,23 @@ Two things worth knowing about what shipped:
| 477 | 477 | copy can be far behind. The controller falls back to the row only when |
| 478 | 478 | no authority can be started. |
| 479 | 479 | |
| 480 | −### Phase 1a — Web "Connected devices" + QR | |
| 481 | −- `eqrcode` dep, public-URL derivation, token panel in `UserLive.Settings`, | |
| 482 | − revoke action. | |
| 480 | +### Phase 1a — Web "Connected devices" + QR ✅ shipped | |
| 481 | +- `eqrcode` dep and `DiogramosWeb.QR.svg/2`. | |
| 482 | +- `DiogramosWeb.PublicUrl.derive/1` for both a `Plug.Conn` and a | |
| 483 | + `LiveView.Socket`; the LiveView socket now exposes `:uri` and | |
| 484 | + `:x_headers` in `connect_info`, which is what the socket flavour reads. | |
| 485 | +- "Connected devices" panel in `UserLive.Settings`: generate (with an | |
| 486 | + optional label), one-time display of token + QR + `diogramos://pair` | |
| 487 | + URI, active-token list, revoke. The whole page is already behind | |
| 488 | + `:require_sudo_mode`, and the generate/revoke handlers re-assert | |
| 489 | + `Accounts.sudo_mode?/1`. | |
| 490 | +- 16 tests, including the forwarded-header matrix and a check that the | |
| 491 | + token rendered on screen is the real bearer. | |
| 492 | + | |
| 493 | +The base URL is derived in `mount/3`, because LiveView discards | |
| 494 | +connect_info afterwards. On the dead render that yields the endpoint URL; | |
| 495 | +the connected mount replaces it with the real origin, and a QR is only | |
| 496 | +ever generated after that. | |
| 483 | 497 | |
| 484 | 498 | ### Phase 1b — Android scaffold + both auth paths |
| 485 | 499 | - Gradle project, theme/typography lifted from waxx, `TokenStore`, |
modified
lib/diogramos_web/endpoint.ex
+4
−2
@@ -15,9 +15,11 @@ defmodule DiogramosWeb.Endpoint do
| 15 | 15 | same_site: "Lax" |
| 16 | 16 | ] |
| 17 | 17 | |
| 18 | + # :uri and :x_headers are what DiogramosWeb.PublicUrl needs to work out | |
| 19 | + # the origin a paired device should dial back to. | |
| 18 | 20 | socket "/live", Phoenix.LiveView.Socket, |
| 19 | − websocket: [connect_info: [session: @session_options]], | |
| 20 | − longpoll: [connect_info: [session: @session_options]] | |
| 21 | + websocket: [connect_info: [:uri, :x_headers, session: @session_options]], | |
| 22 | + longpoll: [connect_info: [:uri, :x_headers, session: @session_options]] | |
| 21 | 23 | |
| 22 | 24 | # Native clients. Authenticates with an "api" bearer token in the query |
| 23 | 25 | # string rather than the browser session, so it deliberately does not |
modified
lib/diogramos_web/live/user_live/settings.ex
+139
−0
@@ -4,6 +4,7 @@ defmodule DiogramosWeb.UserLive.Settings do
| 4 | 4 | on_mount {DiogramosWeb.UserAuth, :require_sudo_mode} |
| 5 | 5 | |
| 6 | 6 | alias Diogramos.Accounts |
| 7 | + alias DiogramosWeb.{PublicUrl, QR} | |
| 7 | 8 | |
| 8 | 9 | @impl true |
| 9 | 10 | def render(assigns) do |
@@ -65,6 +66,90 @@ defmodule DiogramosWeb.UserLive.Settings do
| 65 | 66 | Save Password |
| 66 | 67 | </.button> |
| 67 | 68 | </.form> |
| 69 | + | |
| 70 | + <div class="divider" /> | |
| 71 | + | |
| 72 | + <section id="api-tokens" class="text-left"> | |
| 73 | + <.header> | |
| 74 | + Connected devices | |
| 75 | + <:subtitle> | |
| 76 | + API tokens issued to native clients. Generate one here and scan the | |
| 77 | + QR with the Android app to pair it. | |
| 78 | + </:subtitle> | |
| 79 | + </.header> | |
| 80 | + | |
| 81 | + <.form for={@token_form} id="token_form" phx-submit="generate_api_token" class="mt-4"> | |
| 82 | + <.input | |
| 83 | + field={@token_form[:label]} | |
| 84 | + type="text" | |
| 85 | + label="Label (optional)" | |
| 86 | + placeholder="e.g. Pixel 7, kitchen" | |
| 87 | + maxlength="80" | |
| 88 | + /> | |
| 89 | + <.button variant="primary" phx-disable-with="Generating..."> | |
| 90 | + Generate token | |
| 91 | + </.button> | |
| 92 | + </.form> | |
| 93 | + | |
| 94 | + <div :if={@just_created} id="new-token" class="card bg-base-200 p-4 mt-6 space-y-3"> | |
| 95 | + <div class="alert alert-warning"> | |
| 96 | + <.icon name="hero-exclamation-triangle" class="size-5" /> | |
| 97 | + <span> | |
| 98 | + This is the only time the token is shown. Scan or copy it now. | |
| 99 | + </span> | |
| 100 | + </div> | |
| 101 | + | |
| 102 | + <div class="flex flex-col md:flex-row items-center gap-4"> | |
| 103 | + <div class="bg-white p-3 rounded"> | |
| 104 | + {raw(@just_created.qr_svg)} | |
| 105 | + </div> | |
| 106 | + <div class="space-y-2 text-sm break-all"> | |
| 107 | + <div class="font-semibold">Server URL</div> | |
| 108 | + <code class="text-xs block">{@just_created.base_url}</code> | |
| 109 | + <div class="font-semibold">Token</div> | |
| 110 | + <code class="text-xs block">{@just_created.token}</code> | |
| 111 | + <div class="font-semibold">Pairing URI</div> | |
| 112 | + <code class="text-xs block">{@just_created.pair_uri}</code> | |
| 113 | + </div> | |
| 114 | + </div> | |
| 115 | + | |
| 116 | + <div> | |
| 117 | + <button type="button" class="btn btn-ghost btn-sm" phx-click="dismiss_token_panel"> | |
| 118 | + Done — hide | |
| 119 | + </button> | |
| 120 | + </div> | |
| 121 | + </div> | |
| 122 | + | |
| 123 | + <div class="mt-6"> | |
| 124 | + <h3 class="font-bold mb-2">Active tokens</h3> | |
| 125 | + <div :if={@api_tokens == []} class="text-sm opacity-70"> | |
| 126 | + No tokens yet. Generate one above to pair a device. | |
| 127 | + </div> | |
| 128 | + <ul :if={@api_tokens != []} class="divide-y divide-base-300"> | |
| 129 | + <li | |
| 130 | + :for={token <- @api_tokens} | |
| 131 | + id={"api-token-#{token.id}"} | |
| 132 | + class="flex items-center justify-between py-2" | |
| 133 | + > | |
| 134 | + <div class="text-sm"> | |
| 135 | + <div class="font-medium">{token.label || "(no label)"}</div> | |
| 136 | + <div class="opacity-70 text-xs"> | |
| 137 | + Issued {format_ts(token.inserted_at)} · Last seen {format_ts(token.authenticated_at)} | |
| 138 | + </div> | |
| 139 | + </div> | |
| 140 | + <button | |
| 141 | + type="button" | |
| 142 | + class="btn btn-ghost btn-sm text-error" | |
| 143 | + phx-click="revoke_api_token" | |
| 144 | + phx-value-id={token.id} | |
| 145 | + data-confirm="Revoke this token? The paired device stops working." | |
| 146 | + > | |
| 147 | + Revoke | |
| 148 | + </button> | |
| 149 | + </li> | |
| 150 | + </ul> | |
| 151 | + </div> | |
| 152 | + </section> | |
| 68 | 153 | </Layouts.app> |
| 69 | 154 | """ |
| 70 | 155 | end |
@@ -94,6 +179,14 @@ defmodule DiogramosWeb.UserLive.Settings do
| 94 | 179 | |> assign(:email_form, to_form(email_changeset)) |
| 95 | 180 | |> assign(:password_form, to_form(password_changeset)) |
| 96 | 181 | |> assign(:trigger_submit, false) |
| 182 | + |> assign(:api_tokens, Accounts.list_api_tokens(user)) | |
| 183 | + |> assign(:token_form, to_form(%{"label" => ""}, as: :token)) | |
| 184 | + |> assign(:just_created, nil) | |
| 185 | + # Derived here because LiveView discards connect_info after mount. | |
| 186 | + # On the dead render this falls back to the endpoint URL; the | |
| 187 | + # connected mount that follows replaces it with the real origin, | |
| 188 | + # and a QR is only ever generated after that. | |
| 189 | + |> assign(:public_base_url, PublicUrl.derive(socket)) | |
| 97 | 190 | |
| 98 | 191 | {:ok, socket} |
| 99 | 192 | end |
@@ -157,4 +250,50 @@ defmodule DiogramosWeb.UserLive.Settings do
| 157 | 250 | {:noreply, assign(socket, password_form: to_form(changeset, action: :insert))} |
| 158 | 251 | end |
| 159 | 252 | end |
| 253 | + | |
| 254 | + def handle_event("generate_api_token", params, socket) do | |
| 255 | + user = socket.assigns.current_scope.user | |
| 256 | + true = Accounts.sudo_mode?(user) | |
| 257 | + | |
| 258 | + label = get_in(params, ["token", "label"]) | |
| 259 | + token = Accounts.create_api_token(user, %{label: label}) | |
| 260 | + | |
| 261 | + base_url = socket.assigns.public_base_url | |
| 262 | + pair_uri = "diogramos://pair?" <> URI.encode_query(%{base: base_url, token: token}) | |
| 263 | + | |
| 264 | + just_created = %{ | |
| 265 | + token: token, | |
| 266 | + base_url: base_url, | |
| 267 | + pair_uri: pair_uri, | |
| 268 | + qr_svg: QR.svg(pair_uri) | |
| 269 | + } | |
| 270 | + | |
| 271 | + {:noreply, | |
| 272 | + socket | |
| 273 | + |> assign(:just_created, just_created) | |
| 274 | + |> assign(:token_form, to_form(%{"label" => ""}, as: :token)) | |
| 275 | + |> assign(:api_tokens, Accounts.list_api_tokens(user))} | |
| 276 | + end | |
| 277 | + | |
| 278 | + def handle_event("dismiss_token_panel", _params, socket) do | |
| 279 | + {:noreply, assign(socket, :just_created, nil)} | |
| 280 | + end | |
| 281 | + | |
| 282 | + def handle_event("revoke_api_token", %{"id" => id}, socket) do | |
| 283 | + user = socket.assigns.current_scope.user | |
| 284 | + true = Accounts.sudo_mode?(user) | |
| 285 | + | |
| 286 | + socket = | |
| 287 | + case Accounts.delete_api_token(user, String.to_integer(id)) do | |
| 288 | + :ok -> put_flash(socket, :info, "Device revoked.") | |
| 289 | + {:error, :not_found} -> put_flash(socket, :error, "That token is already gone.") | |
| 290 | + end | |
| 291 | + | |
| 292 | + # Re-read rather than filter in place: a revoked token must never | |
| 293 | + # stay on screen looking like it still works. | |
| 294 | + {:noreply, assign(socket, :api_tokens, Accounts.list_api_tokens(user))} | |
| 295 | + end | |
| 296 | + | |
| 297 | + defp format_ts(nil), do: "never" | |
| 298 | + defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC") | |
| 160 | 299 | end |
added
lib/diogramos_web/public_url.ex
+130
−0
@@ -0,0 +1,130 @@
| 1 | +defmodule DiogramosWeb.PublicUrl do | |
| 2 | + @moduledoc """ | |
| 3 | + Derives the public-facing origin (`scheme://host[:port]`) a native | |
| 4 | + client should dial back to. | |
| 5 | + | |
| 6 | + Resolution, highest precedence first: | |
| 7 | + | |
| 8 | + 1. `DIOGRAMOS_PUBLIC_URL` (explicit override for reverse-proxy setups). | |
| 9 | + 2. Composed from forwarded headers with conn/socket fallbacks: | |
| 10 | + * scheme: `X-Forwarded-Proto` if present, else the request's scheme | |
| 11 | + * host: `X-Forwarded-Host` if present (it may already carry `:port`), | |
| 12 | + else the request's host, with the port appended when non-default | |
| 13 | + 3. The endpoint's configured URL, as a last resort. | |
| 14 | + | |
| 15 | + Scheme and host are sourced independently, which is the whole point: a | |
| 16 | + proxy that sets only `X-Forwarded-Proto: https` and passes Host through | |
| 17 | + unchanged — the common Traefik/nginx default — must still yield an | |
| 18 | + `https` origin. Treating the two as a unit encodes `http://` into the | |
| 19 | + pairing QR behind a TLS-terminating proxy, and the phone then fails its | |
| 20 | + WebSocket upgrade. | |
| 21 | + | |
| 22 | + The result never has a trailing slash, so callers can concatenate | |
| 23 | + `"\#{base}/api/v1/..."` without producing a double slash. | |
| 24 | + """ | |
| 25 | + | |
| 26 | + import Plug.Conn, only: [get_req_header: 2] | |
| 27 | + | |
| 28 | + @spec derive(Plug.Conn.t() | Phoenix.LiveView.Socket.t()) :: String.t() | |
| 29 | + def derive(%Plug.Conn{} = conn) do | |
| 30 | + env_override() || compose(scheme_from_conn(conn), host_from_conn(conn)) || endpoint_url() | |
| 31 | + end | |
| 32 | + | |
| 33 | + @doc """ | |
| 34 | + LiveView flavour. Must be called from `mount/3` — LiveView discards | |
| 35 | + connect_info afterwards — and the endpoint must expose `:uri` and | |
| 36 | + `:x_headers` in the socket's `connect_info`. | |
| 37 | + | |
| 38 | + On the dead render there is no connect_info at all, so this falls | |
| 39 | + through to the endpoint URL; callers that need the real origin should | |
| 40 | + derive it once the socket is connected. | |
| 41 | + """ | |
| 42 | + def derive(%Phoenix.LiveView.Socket{} = socket) do | |
| 43 | + env_override() || compose(scheme_from_socket(socket), host_from_socket(socket)) || | |
| 44 | + endpoint_url() | |
| 45 | + end | |
| 46 | + | |
| 47 | + ## Scheme and host extraction | |
| 48 | + | |
| 49 | + defp scheme_from_conn(conn) do | |
| 50 | + forwarded_proto(get_req_header(conn, "x-forwarded-proto")) || to_string(conn.scheme) | |
| 51 | + end | |
| 52 | + | |
| 53 | + defp host_from_conn(conn) do | |
| 54 | + case get_req_header(conn, "x-forwarded-host") do | |
| 55 | + [host | _] -> first_value(host) | |
| 56 | + _ -> host_with_port(conn.host, to_string(conn.scheme), conn.port) | |
| 57 | + end | |
| 58 | + end | |
| 59 | + | |
| 60 | + defp scheme_from_socket(socket) do | |
| 61 | + forwarded_proto([header(socket, "x-forwarded-proto")]) || | |
| 62 | + case connect_uri(socket) do | |
| 63 | + %URI{scheme: scheme} when is_binary(scheme) -> scheme | |
| 64 | + _ -> nil | |
| 65 | + end | |
| 66 | + end | |
| 67 | + | |
| 68 | + defp host_from_socket(socket) do | |
| 69 | + case header(socket, "x-forwarded-host") do | |
| 70 | + nil -> | |
| 71 | + case connect_uri(socket) do | |
| 72 | + %URI{host: host, scheme: scheme, port: port} when is_binary(host) -> | |
| 73 | + host_with_port(host, scheme, port) | |
| 74 | + | |
| 75 | + _ -> | |
| 76 | + nil | |
| 77 | + end | |
| 78 | + | |
| 79 | + host -> | |
| 80 | + first_value(host) | |
| 81 | + end | |
| 82 | + end | |
| 83 | + | |
| 84 | + defp connect_uri(socket), do: Phoenix.LiveView.get_connect_info(socket, :uri) | |
| 85 | + | |
| 86 | + defp header(socket, name) do | |
| 87 | + socket | |
| 88 | + |> Phoenix.LiveView.get_connect_info(:x_headers) | |
| 89 | + |> Kernel.||([]) | |
| 90 | + |> Enum.find_value(fn | |
| 91 | + {^name, value} -> value | |
| 92 | + _ -> nil | |
| 93 | + end) | |
| 94 | + end | |
| 95 | + | |
| 96 | + ## Helpers | |
| 97 | + | |
| 98 | + defp compose(nil, _host), do: nil | |
| 99 | + defp compose(_scheme, nil), do: nil | |
| 100 | + defp compose(scheme, host), do: strip_trailing_slash("#{scheme}://#{host}") | |
| 101 | + | |
| 102 | + defp forwarded_proto([proto | _]) when is_binary(proto), do: first_value(proto) | |
| 103 | + defp forwarded_proto(_), do: nil | |
| 104 | + | |
| 105 | + # Chained proxies append rather than replace, so these headers can be a | |
| 106 | + # comma-separated list. The first value is the one closest to the client. | |
| 107 | + defp first_value(value), do: value |> String.split(",") |> List.first() |> String.trim() | |
| 108 | + | |
| 109 | + defp host_with_port(host, _scheme, nil), do: host | |
| 110 | + | |
| 111 | + defp host_with_port(host, scheme, port) do | |
| 112 | + if default_port?(scheme, port), do: host, else: "#{host}:#{port}" | |
| 113 | + end | |
| 114 | + | |
| 115 | + defp default_port?("http", 80), do: true | |
| 116 | + defp default_port?("https", 443), do: true | |
| 117 | + defp default_port?(_scheme, _port), do: false | |
| 118 | + | |
| 119 | + defp env_override do | |
| 120 | + case System.get_env("DIOGRAMOS_PUBLIC_URL") do | |
| 121 | + nil -> nil | |
| 122 | + "" -> nil | |
| 123 | + url -> strip_trailing_slash(url) | |
| 124 | + end | |
| 125 | + end | |
| 126 | + | |
| 127 | + defp endpoint_url, do: strip_trailing_slash(DiogramosWeb.Endpoint.url()) | |
| 128 | + | |
| 129 | + defp strip_trailing_slash(url), do: String.replace_suffix(url, "/", "") | |
| 130 | +end |
added
lib/diogramos_web/qr.ex
+22
−0
@@ -0,0 +1,22 @@
| 1 | +defmodule DiogramosWeb.QR do | |
| 2 | + @moduledoc """ | |
| 3 | + Renders QR codes to inline SVG, for embedding in a HEEx template via | |
| 4 | + `raw/1`. | |
| 5 | + """ | |
| 6 | + | |
| 7 | + @default_width 280 | |
| 8 | + | |
| 9 | + @doc """ | |
| 10 | + Renders `payload` as an SVG QR code, `width` pixels square (default | |
| 11 | + #{@default_width}). | |
| 12 | + | |
| 13 | + Raises if the payload exceeds what the encoder can hold — the limit is | |
| 14 | + over 2KB, and a pairing URI is a couple of hundred bytes. | |
| 15 | + """ | |
| 16 | + @spec svg(String.t(), keyword()) :: String.t() | |
| 17 | + def svg(payload, opts \\ []) when is_binary(payload) do | |
| 18 | + payload | |
| 19 | + |> EQRCode.encode() | |
| 20 | + |> EQRCode.svg(width: Keyword.get(opts, :width, @default_width)) | |
| 21 | + end | |
| 22 | +end |
modified
mix.exs
+3
−0
@@ -69,6 +69,9 @@ defmodule Diogramos.MixProject do
| 69 | 69 | # describe all of them, and a plugin rename between minors would only |
| 70 | 70 | # show up as an empty panel. |
| 71 | 71 | {:prom_ex, "~> 1.11.0"}, |
| 72 | + # Renders the device-pairing QR on the settings page. Pure Elixir, | |
| 73 | + # SVG output -- no binary or JS dependency for one small image. | |
| 74 | + {:eqrcode, "~> 0.2.1"}, | |
| 72 | 75 | {:gettext, "~> 1.0"}, |
| 73 | 76 | {:jason, "~> 1.2"}, |
| 74 | 77 | {:dns_cluster, "~> 0.2.0"}, |
modified
mix.lock
+1
−0
@@ -12,6 +12,7 @@
| 12 | 12 | "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, |
| 13 | 13 | "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, |
| 14 | 14 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, |
| 15 | + "eqrcode": {:hex, :eqrcode, "0.2.1", "d12838813e8fc87b8940cc05f9baadb189031f6009facdc56ff074375ec73b6e", [:mix], [], "hexpm", "d5828a222b904c68360e7dc2a40c3ef33a1328b7c074583898040f389f928025"}, | |
| 15 | 16 | "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, |
| 16 | 17 | "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, |
| 17 | 18 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, |
added
test/diogramos_web/live/user_live/connected_devices_test.exs
+128
−0
@@ -0,0 +1,128 @@
| 1 | +defmodule DiogramosWeb.UserLive.ConnectedDevicesTest do | |
| 2 | + use DiogramosWeb.ConnCase, async: false | |
| 3 | + | |
| 4 | + import Phoenix.LiveViewTest | |
| 5 | + import Diogramos.AccountsFixtures | |
| 6 | + | |
| 7 | + alias Diogramos.Accounts | |
| 8 | + | |
| 9 | + setup %{conn: conn} do | |
| 10 | + on_exit(fn -> System.delete_env("DIOGRAMOS_PUBLIC_URL") end) | |
| 11 | + | |
| 12 | + user = user_fixture() | |
| 13 | + %{conn: log_in_user(conn, user), user: user} | |
| 14 | + end | |
| 15 | + | |
| 16 | + describe "the panel" do | |
| 17 | + test "shows an empty state before any device is paired", %{conn: conn} do | |
| 18 | + {:ok, _lv, html} = live(conn, ~p"/users/settings") | |
| 19 | + | |
| 20 | + assert html =~ "Connected devices" | |
| 21 | + assert html =~ "No tokens yet" | |
| 22 | + end | |
| 23 | + | |
| 24 | + test "lists existing tokens with their labels", %{conn: conn, user: user} do | |
| 25 | + Accounts.create_api_token(user, %{label: "Pixel 7"}) | |
| 26 | + | |
| 27 | + {:ok, _lv, html} = live(conn, ~p"/users/settings") | |
| 28 | + | |
| 29 | + assert html =~ "Pixel 7" | |
| 30 | + refute html =~ "No tokens yet" | |
| 31 | + end | |
| 32 | + | |
| 33 | + test "labels an unlabelled token rather than rendering a blank row", %{ | |
| 34 | + conn: conn, | |
| 35 | + user: user | |
| 36 | + } do | |
| 37 | + Accounts.create_api_token(user) | |
| 38 | + | |
| 39 | + {:ok, _lv, html} = live(conn, ~p"/users/settings") | |
| 40 | + assert html =~ "(no label)" | |
| 41 | + end | |
| 42 | + | |
| 43 | + test "does not list another user's tokens", %{conn: conn} do | |
| 44 | + Accounts.create_api_token(user_fixture(), %{label: "Someone else's phone"}) | |
| 45 | + | |
| 46 | + {:ok, _lv, html} = live(conn, ~p"/users/settings") | |
| 47 | + | |
| 48 | + refute html =~ "Someone else's phone" | |
| 49 | + assert html =~ "No tokens yet" | |
| 50 | + end | |
| 51 | + end | |
| 52 | + | |
| 53 | + describe "generating a token" do | |
| 54 | + test "renders a working token, a QR, and the pairing URI", %{conn: conn, user: user} do | |
| 55 | + System.put_env("DIOGRAMOS_PUBLIC_URL", "https://diagrams.example.com") | |
| 56 | + | |
| 57 | + {:ok, lv, _html} = live(conn, ~p"/users/settings") | |
| 58 | + | |
| 59 | + html = | |
| 60 | + lv | |
| 61 | + |> form("#token_form", token: %{label: "Pixel 7"}) | |
| 62 | + |> render_submit() | |
| 63 | + | |
| 64 | + assert html =~ "diogramos://pair?" | |
| 65 | + assert html =~ "https%3A%2F%2Fdiagrams.example.com" | |
| 66 | + assert html =~ "<svg" | |
| 67 | + assert html =~ "only time the token is shown" | |
| 68 | + | |
| 69 | + # The token on screen is the real bearer, not a display stand-in. | |
| 70 | + [_, token] = Regex.run(~r/token=([A-Za-z0-9_-]+)/, html) | |
| 71 | + assert {fetched, _id} = Accounts.fetch_user_by_api_token(token) | |
| 72 | + assert fetched.id == user.id | |
| 73 | + | |
| 74 | + assert [%{label: "Pixel 7"}] = Accounts.list_api_tokens(user) | |
| 75 | + end | |
| 76 | + | |
| 77 | + test "an empty label is stored as none", %{conn: conn, user: user} do | |
| 78 | + {:ok, lv, _html} = live(conn, ~p"/users/settings") | |
| 79 | + | |
| 80 | + lv |> form("#token_form", token: %{label: ""}) |> render_submit() | |
| 81 | + | |
| 82 | + assert [%{label: nil}] = Accounts.list_api_tokens(user) | |
| 83 | + end | |
| 84 | + | |
| 85 | + test "the panel can be dismissed, and the token is not shown again", %{conn: conn} do | |
| 86 | + {:ok, lv, _html} = live(conn, ~p"/users/settings") | |
| 87 | + | |
| 88 | + html = lv |> form("#token_form", token: %{label: "Pixel"}) |> render_submit() | |
| 89 | + assert html =~ "diogramos://pair?" | |
| 90 | + | |
| 91 | + html = lv |> element("button", "Done — hide") |> render_click() | |
| 92 | + | |
| 93 | + refute html =~ "diogramos://pair?" | |
| 94 | + # ...but the device itself is still listed. | |
| 95 | + assert html =~ "Pixel" | |
| 96 | + end | |
| 97 | + end | |
| 98 | + | |
| 99 | + describe "revoking" do | |
| 100 | + test "removes the row and kills the token", %{conn: conn, user: user} do | |
| 101 | + token = Accounts.create_api_token(user, %{label: "Old phone"}) | |
| 102 | + [%{id: id}] = Accounts.list_api_tokens(user) | |
| 103 | + | |
| 104 | + {:ok, lv, html} = live(conn, ~p"/users/settings") | |
| 105 | + assert html =~ "Old phone" | |
| 106 | + | |
| 107 | + html = lv |> element("#api-token-#{id} button", "Revoke") |> render_click() | |
| 108 | + | |
| 109 | + refute html =~ "Old phone" | |
| 110 | + assert html =~ "No tokens yet" | |
| 111 | + refute Accounts.fetch_user_by_api_token(token) | |
| 112 | + end | |
| 113 | + | |
| 114 | + test "cannot revoke a token belonging to someone else", %{conn: conn, user: user} do | |
| 115 | + other = user_fixture() | |
| 116 | + their_token = Accounts.create_api_token(other) | |
| 117 | + [%{id: their_id}] = Accounts.list_api_tokens(other) | |
| 118 | + | |
| 119 | + {:ok, lv, _html} = live(conn, ~p"/users/settings") | |
| 120 | + | |
| 121 | + render_click(lv, "revoke_api_token", %{"id" => to_string(their_id)}) | |
| 122 | + | |
| 123 | + # Still valid: the id belongs to another user, so the delete is scoped out. | |
| 124 | + assert Accounts.fetch_user_by_api_token(their_token) | |
| 125 | + assert Accounts.list_api_tokens(user) == [] | |
| 126 | + end | |
| 127 | + end | |
| 128 | +end |
added
test/diogramos_web/public_url_test.exs
+64
−0
@@ -0,0 +1,64 @@
| 1 | +defmodule DiogramosWeb.PublicUrlTest do | |
| 2 | + use DiogramosWeb.ConnCase, async: false | |
| 3 | + | |
| 4 | + alias DiogramosWeb.PublicUrl | |
| 5 | + | |
| 6 | + setup do | |
| 7 | + on_exit(fn -> System.delete_env("DIOGRAMOS_PUBLIC_URL") end) | |
| 8 | + :ok | |
| 9 | + end | |
| 10 | + | |
| 11 | + defp conn_at(host, port, scheme, headers \\ []) do | |
| 12 | + conn = %{Phoenix.ConnTest.build_conn() | host: host, port: port, scheme: scheme} | |
| 13 | + | |
| 14 | + Enum.reduce(headers, conn, fn {k, v}, acc -> Plug.Conn.put_req_header(acc, k, v) end) | |
| 15 | + end | |
| 16 | + | |
| 17 | + test "uses the env override above everything else" do | |
| 18 | + System.put_env("DIOGRAMOS_PUBLIC_URL", "https://diagrams.example.com/") | |
| 19 | + | |
| 20 | + conn = conn_at("localhost", 4000, :http, [{"x-forwarded-proto", "http"}]) | |
| 21 | + assert PublicUrl.derive(conn) == "https://diagrams.example.com" | |
| 22 | + end | |
| 23 | + | |
| 24 | + test "falls back to the request's own scheme and host" do | |
| 25 | + assert PublicUrl.derive(conn_at("example.com", 80, :http)) == "http://example.com" | |
| 26 | + assert PublicUrl.derive(conn_at("example.com", 443, :https)) == "https://example.com" | |
| 27 | + end | |
| 28 | + | |
| 29 | + test "keeps a non-default port, which is what LAN and emulator use" do | |
| 30 | + assert PublicUrl.derive(conn_at("192.168.1.50", 4000, :http)) == "http://192.168.1.50:4000" | |
| 31 | + end | |
| 32 | + | |
| 33 | + test "honours x-forwarded-proto while the proxy passes Host through" do | |
| 34 | + # The Traefik/nginx default, and the case that matters: getting this | |
| 35 | + # wrong puts http:// in the QR behind a TLS-terminating proxy. | |
| 36 | + conn = conn_at("diagrams.example.com", 80, :http, [{"x-forwarded-proto", "https"}]) | |
| 37 | + assert PublicUrl.derive(conn) == "https://diagrams.example.com" | |
| 38 | + end | |
| 39 | + | |
| 40 | + test "honours x-forwarded-host" do | |
| 41 | + conn = | |
| 42 | + conn_at("10.0.0.5", 4000, :http, [ | |
| 43 | + {"x-forwarded-proto", "https"}, | |
| 44 | + {"x-forwarded-host", "diagrams.example.com"} | |
| 45 | + ]) | |
| 46 | + | |
| 47 | + assert PublicUrl.derive(conn) == "https://diagrams.example.com" | |
| 48 | + end | |
| 49 | + | |
| 50 | + test "takes the client-most value from a chained proxy list" do | |
| 51 | + conn = | |
| 52 | + conn_at("10.0.0.5", 4000, :http, [ | |
| 53 | + {"x-forwarded-proto", "https, http"}, | |
| 54 | + {"x-forwarded-host", "edge.example.com, inner.example.com"} | |
| 55 | + ]) | |
| 56 | + | |
| 57 | + assert PublicUrl.derive(conn) == "https://edge.example.com" | |
| 58 | + end | |
| 59 | + | |
| 60 | + test "an x-forwarded-host carrying its own port is passed through" do | |
| 61 | + conn = conn_at("10.0.0.5", 4000, :http, [{"x-forwarded-host", "example.com:8443"}]) | |
| 62 | + assert PublicUrl.derive(conn) == "http://example.com:8443" | |
| 63 | + end | |
| 64 | +end |
Parents: bfa2c00