6.1 KiB · text History 6d24830
defmodule FeedPugWeb.DevicesLive do
  @moduledoc """
  Manage API tokens for mobile/API clients and render the QR pairing code.

  The QR encodes `feedpug://pair?base=<server>&token=<api-token>`, which the
  Android app scans (or opens as a deep link) to store its credentials.
  """
  use FeedPugWeb, :live_view

  alias FeedPug.Accounts

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket |> assign(:shown_token, nil) |> load_tokens()}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <h1 class="text-2xl font-bold">Devices &amp; API tokens</h1>
      <p class="text-sm opacity-70">
        Generate a token, then scan its QR code from the FeedPug Android app to pair.
        Treat tokens like passwords — anyone with one can read your feeds.
      </p>

      <.form
        for={@form}
        id="token-form"
        phx-submit="create_token"
        class="flex flex-wrap items-end gap-2 rounded-lg border border-base-content/10 p-4"
      >
        <div class="flex-1">
          <.input field={@form[:label]} type="text" label="Label" placeholder="My phone" />
        </div>
        <div class="w-32">
          <.input field={@form[:days]} type="number" label="Expires (days)" placeholder="never" />
        </div>
        <button class="btn btn-primary">Generate token</button>
      </.form>

      <div
        :if={@shown_token}
        id="pairing"
        class="rounded-lg border border-primary/40 bg-base-200 p-4 text-center space-y-3"
      >
        <div class="flex items-center justify-between">
          <h2 class="font-semibold">Pair “{@shown_token.label || "token"}”</h2>
          <button phx-click="hide_qr" class="btn btn-ghost btn-xs">close</button>
        </div>
        <div class="mx-auto w-56">{raw(qr_svg(pair_uri(@shown_token)))}</div>
        <div class="space-y-1">
          <p class="text-xs opacity-60">Or paste this into the app:</p>
          <code class="block break-all rounded bg-base-300 p-2 text-xs">
            {pair_uri(@shown_token)}
          </code>
        </div>
      </div>

      <div class="space-y-2">
        <div
          :for={token <- @tokens}
          id={"token-#{token.id}"}
          class="flex items-center justify-between rounded-lg border border-base-content/10 p-3"
        >
          <div class="min-w-0">
            <p class="font-medium">{token.label || "Unnamed token"}</p>
            <p class="text-xs opacity-50">
              created {format_time(token.inserted_at)} · {if token.last_used_at,
                do: "last used #{format_time(token.last_used_at)}",
                else: "never used"}
              {if token.expires_at, do: " · expires #{format_time(token.expires_at)}"}
            </p>
          </div>
          <div class="flex items-center gap-1">
            <button
              :if={is_nil(token.last_used_at)}
              phx-click="show_qr"
              phx-value-id={token.id}
              class="btn btn-ghost btn-xs gap-1"
            >
              <.icon name="hero-qr-code-micro" class="size-4" /> Pair
            </button>
            <span :if={token.last_used_at} class="badge badge-ghost badge-sm">paired</span>
            <button
              phx-click="delete_token"
              phx-value-id={token.id}
              data-confirm="Revoke this token? Devices using it will be logged out."
              class="btn btn-ghost btn-xs text-error"
            >
              Revoke
            </button>
          </div>
        </div>
        <p :if={@tokens == []} class="text-sm opacity-50">No tokens yet.</p>
      </div>
    </Layouts.app>
    """
  end

  @impl true
  def handle_event("create_token", %{"token" => params}, socket) do
    opts =
      [label: params["label"]]
      |> maybe_days(params["days"])

    case Accounts.create_api_token(socket.assigns.current_scope, opts) do
      {:ok, token} ->
        {:noreply, socket |> assign(:shown_token, token) |> load_tokens()}

      {:error, _changeset} ->
        {:noreply, put_flash(socket, :error, "Could not create token")}
    end
  end

  def handle_event("show_qr", %{"id" => id}, socket) do
    # Re-read from the DB: a device may have paired since the page loaded.
    socket = refresh_tokens(socket)
    token = Enum.find(socket.assigns.tokens, &(to_string(&1.id) == id))

    cond do
      is_nil(token) ->
        {:noreply, socket}

      is_nil(token.last_used_at) ->
        {:noreply, assign(socket, :shown_token, token)}

      true ->
        {:noreply,
         socket
         |> assign(:shown_token, nil)
         |> put_flash(
           :error,
           "That token has already been used — generate a new one to pair another device."
         )}
    end
  end

  def handle_event("hide_qr", _params, socket), do: {:noreply, assign(socket, :shown_token, nil)}

  def handle_event("delete_token", %{"id" => id}, socket) do
    {:ok, _} = Accounts.delete_api_token(socket.assigns.current_scope, id)

    shown =
      if to_string(socket.assigns.shown_token && socket.assigns.shown_token.id) == id,
        do: nil,
        else: socket.assigns.shown_token

    {:noreply, socket |> assign(:shown_token, shown) |> load_tokens()}
  end

  ## Helpers

  defp load_tokens(socket) do
    socket
    |> refresh_tokens()
    |> assign(:form, to_form(%{"label" => "", "days" => ""}, as: :token))
  end

  defp refresh_tokens(socket) do
    assign(socket, :tokens, Accounts.list_api_tokens(socket.assigns.current_scope))
  end

  defp maybe_days(opts, days) do
    case Integer.parse(to_string(days)) do
      {n, _} when n > 0 -> Keyword.put(opts, :expires_in_days, n)
      _ -> opts
    end
  end

  defp pair_uri(token) do
    "feedpug://pair?base=#{URI.encode_www_form(base_url())}&token=#{URI.encode_www_form(token.token)}"
  end

  defp base_url do
    (System.get_env("FEEDPUG_PUBLIC_URL") || FeedPugWeb.Endpoint.url())
    |> String.trim_trailing("/")
  end

  defp qr_svg(uri) do
    uri
    |> EQRCode.encode()
    |> EQRCode.svg(width: 220, color: "#000000", background_color: "#ffffff")
  end

  defp format_time(nil), do: "—"
  defp format_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
end