lib/openagents_web/live/device_authorization_live.ex

main at 436d37facc96 · 7 KB

defmodule OpenAgentsWeb.DeviceAuthorizationLive do
  @moduledoc "Authenticated review and approval for one CLI device code."

  use OpenAgentsWeb, :live_view

  alias OpenAgents.{ApiTokens, DeviceAuthorizations}

  @impl true
  def mount(params, _session, socket) do
    user_code = params |> Map.get("user_code", "") |> normalize_code()

    {:ok,
     socket
     |> assign(:page_title, "Authorize OpenAgents CLI")
     |> assign_authorization(DeviceAuthorizations.get_pending_by_user_code(user_code))
     |> assign(:decided, nil)
     |> assign(:form, code_form(user_code))}
  end

  @impl true
  def handle_event("lookup", %{"device" => %{"user_code" => user_code}}, socket) do
    user_code = normalize_code(user_code)

    {:noreply,
     socket
     |> assign_authorization(DeviceAuthorizations.get_pending_by_user_code(user_code))
     |> assign(:decided, nil)
     |> assign(:form, code_form(user_code))}
  end

  def handle_event("approve", _params, socket) do
    decide(socket, &DeviceAuthorizations.approve/2, :approved)
  end

  def handle_event("deny", _params, socket) do
    decide(socket, &DeviceAuthorizations.deny/2, :denied)
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      current_scope={@current_scope}
      title="Authorize CLI"
      sidebar_sections={assigns[:sidebar_sections]}
    >
      <main id="device-authorization" class="mx-auto w-full max-w-xl space-y-8 px-4 py-12">
        <.header>
          Authorize OpenAgents CLI
          <:subtitle>
            Confirm that the code in your browser matches the code shown in your terminal.
          </:subtitle>
        </.header>

        <.alert :if={@decided == :approved} id="device-approved" variant={:success}>
          Authorization approved. Return to your terminal to finish signing in.
        </.alert>

        <.alert :if={@decided == :denied} id="device-denied" variant={:warning}>
          Authorization denied. You can close this page.
        </.alert>

        <.card :if={is_nil(@decided)}>
          <.form for={@form} id="device-code-form" phx-submit="lookup" class="space-y-4">
            <.input
              field={@form[:user_code]}
              label="Code from your terminal"
              placeholder="ABCD-EFGH"
              maxlength="9"
              autocomplete="one-time-code"
              required
            />
            <.button id="review-device-code" type="submit" variant={:secondary}>
              Review code
            </.button>
          </.form>
        </.card>

        <.alert
          :if={is_nil(@decided) and @form[:user_code].value != "" and is_nil(@authorization)}
          id="device-code-invalid"
          variant={:danger}
        >
          This code is invalid or expired. Start a new login from the CLI.
        </.alert>

        <.card :if={@authorization && is_nil(@decided)} id="device-authorization-review">
          <div class="space-y-5">
            <div>
              <p class="text-sm text-muted-foreground">Terminal code</p>
              <code class="text-2xl font-semibold tracking-widest">{@form[:user_code].value}</code>
            </div>
            <div>
              <%!-- Approving is a grant, and a grant with no named grantee is a
              reflex rather than a decision. The CLI supplies the computer name
              for recognition; the server supplies the scopes and treats the
              name as display metadata, never as authority. Older clients leave
              it empty and are identified honestly as unnamed. --%>
              <p
                id="device-requesting-computer"
                class="font-medium"
                data-device-name={@authorization.device_name}
              >
                The OpenAgents CLI on
                <span class="font-semibold">{@authorization.device_name || "an unnamed computer"}</span>
                is asking to act as you
              </p>
              <p class="mt-1 text-sm text-muted-foreground">
                Approving gives it these permissions, and no others:
              </p>
              <ul class="mt-2 space-y-1 text-sm text-muted-foreground">
                <li :for={scope <- @authorization.scopes} class="flex items-baseline gap-2">
                  <code>{scope}</code>
                  <span>{scope_description(scope)}</span>
                </li>
              </ul>
              <p :if={@privileged?} class="mt-3 text-sm font-medium text-foreground">
                This request includes operator authority over the OpenAgents fleet. Authorize it
                only if you started this login yourself.
              </p>
              <p class="mt-3 text-sm text-muted-foreground">
                The CLI never receives your GitHub token. This request expires {expires_in(
                  @authorization
                )}; after that the terminal has to ask again.
              </p>
            </div>
            <div class="flex flex-wrap justify-end gap-3">
              <.button id="deny-device" phx-click="deny" variant={:secondary}>Deny</.button>
              <.button id="approve-device" phx-click="approve">Authorize CLI</.button>
            </div>
          </div>
        </.card>
      </main>
    </Layouts.app>
    """
  end

  defp assign_authorization(socket, authorization) do
    socket
    |> assign(:authorization, authorization)
    |> assign(:privileged?, authorization != nil and ApiTokens.privileged?(authorization.scopes))
  end

  defp decide(%{assigns: %{authorization: nil}} = socket, _transition, _decision),
    do: {:noreply, put_flash(socket, :error, "This code is invalid or expired.")}

  defp decide(socket, transition, decision) do
    user_code = socket.assigns.form[:user_code].value

    case transition.(user_code, socket.assigns.current_user) do
      {:ok, _authorization} ->
        {:noreply,
         socket
         |> assign_authorization(nil)
         |> assign(:decided, decision)}

      {:error, _reason} ->
        {:noreply,
         socket
         |> assign_authorization(nil)
         |> put_flash(:error, "This code is invalid or expired.")}
    end
  end

  defp code_form(user_code), do: to_form(%{"user_code" => user_code}, as: :device)

  # Rendered once at mount and not counted down. A ticking clock would make the
  # page a timer, and the window is ten minutes: what the reader needs is that
  # there is one, not the second it lands on.
  defp expires_in(%{expires_at: expires_at}) do
    case DateTime.diff(expires_at, DateTime.utc_now(), :second) do
      seconds when seconds <= 60 -> "in under a minute"
      seconds -> "in about #{div(seconds + 30, 60)} minutes"
    end
  end

  defp scope_description("forge:write"),
    do: "Create and manage repositories, issues, and pull requests as you."

  defp scope_description("deployments:promote"),
    do: "Promote a pushed commit as the OpenAgents fleet target."

  defp scope_description("deployments:write"), do: "Deploy a repository you can write to."
  defp scope_description("chat:account"), do: "Read and write your account chat."
  defp scope_description("box:control"), do: "Start and stop Boxes in your conversations."
  defp scope_description("computer:control"), do: "Control the Computers you have connected."
  defp scope_description(_scope), do: "Scoped access to one OpenAgents surface."

  defp normalize_code(code) when is_binary(code),
    do: code |> String.trim() |> String.upcase()

  defp normalize_code(_code), do: ""
end