lib/openagents_web/live/admin_scv_accounts_live.ex

436d37facc96 · 13 KB

defmodule OpenAgentsWeb.AdminScvAccountsLive do
  @moduledoc "Operator-only connection surface for individual Codex accounts used by SCVs."

  use OpenAgentsWeb, :live_view

  alias OpenAgents.Accounts
  alias OpenAgents.SCV.CodexAccounts

  @impl true
  def mount(_params, _session, socket) do
    if Accounts.admin?(socket.assigns.current_user) do
      if connected?(socket), do: CodexAccounts.subscribe()

      {pending, interrupted_attempt_id} = recover_pending(socket.assigns.current_user)

      {:ok,
       socket
       |> assign(:page_title, "Operator · SCV Codex accounts")
       |> assign(:codex_enabled, CodexAccounts.enabled?())
       |> assign(:pending, pending)
       |> assign(:interrupted_attempt_id, interrupted_attempt_id)
       |> assign(:form, to_form(%{"label" => ""}, as: :account))
       |> load_accounts()}
    else
      {:ok, redirect(socket, to: ~p"/")}
    end
  end

  @impl true
  def handle_event("connect_account", %{"account" => attributes}, socket) do
    with true <- Accounts.admin?(socket.assigns.current_user),
         {:ok, _account, _attempt, ceremony} <-
           CodexAccounts.start_device_login(socket.assigns.current_user, attributes) do
      {:noreply,
       socket
       |> assign(:pending, ceremony)
       |> assign(:interrupted_attempt_id, nil)
       |> assign(:form, to_form(%{"label" => ""}, as: :account))
       |> put_flash(:info, "Codex supplied a one-time device code.")
       |> load_accounts()}
    else
      false ->
        {:noreply, redirect(socket, to: ~p"/")}

      {:error, reason} ->
        {:noreply, put_flash(socket, :error, error_message(reason))}
    end
  end

  def handle_event("cancel_login", _params, socket) do
    if Accounts.admin?(socket.assigns.current_user) do
      result =
        cond do
          match?(%{attempt_id: _attempt_id}, socket.assigns.pending) ->
            CodexAccounts.cancel_device_login(
              socket.assigns.current_user,
              socket.assigns.pending.attempt_id
            )

          is_binary(socket.assigns.interrupted_attempt_id) ->
            CodexAccounts.cancel_device_login(
              socket.assigns.current_user,
              socket.assigns.interrupted_attempt_id
            )

          true ->
            {:error, :login_not_found}
        end

      socket =
        case result do
          :ok ->
            socket
            |> assign(:pending, nil)
            |> assign(:interrupted_attempt_id, nil)
            |> put_flash(:info, "Codex login cancelled. You can start a new connection.")

          {:error, reason} ->
            put_flash(socket, :error, error_message(reason))
        end

      {:noreply, load_accounts(socket)}
    else
      {:noreply, redirect(socket, to: ~p"/")}
    end
  end

  @impl true
  def handle_info({:scv_codex_accounts, {:account_ready, account_id}}, socket) do
    pending = clear_pending(socket.assigns.pending, account_id)

    {:noreply,
     socket
     |> assign(:pending, pending)
     |> assign(:interrupted_attempt_id, nil)
     |> put_flash(:info, "Codex account connected and verified for SCVs.")
     |> load_accounts()}
  end

  def handle_info({:scv_codex_accounts, {:account_failed, account_id, code}}, socket) do
    pending = clear_pending(socket.assigns.pending, account_id)

    {:noreply,
     socket
     |> assign(:pending, pending)
     |> assign(:interrupted_attempt_id, nil)
     |> put_flash(:error, error_message(code))
     |> load_accounts()}
  end

  def handle_info({:scv_codex_accounts, {:account_cancelled, account_id}}, socket) do
    {:noreply,
     socket
     |> assign(:pending, clear_pending(socket.assigns.pending, account_id))
     |> assign(:interrupted_attempt_id, nil)
     |> load_accounts()}
  end

  def handle_info(_message, socket), do: {:noreply, socket}

  defp load_accounts(socket), do: assign(socket, :accounts, CodexAccounts.list_accounts())

  defp clear_pending(%{account_id: account_id}, account_id), do: nil
  defp clear_pending(pending, _account_id), do: pending

  defp recover_pending(operator) do
    case CodexAccounts.recover_device_login(operator) do
      {:ok, ceremony} -> {ceremony, nil}
      {:error, :login_not_running, attempt_id} -> {nil, attempt_id}
      :none -> {nil, nil}
    end
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title="SCV Codex accounts"
    >
      <main id="admin-scv-accounts-page" class="app-shell admin-shell">
        <section class="admin space-y-8" aria-labelledby="scv-codex-heading">
          <header class="admin-heading">
            <h1 id="scv-codex-heading">Codex accounts for SCVs</h1>
            <p>
              Connect an individual Codex account through OpenAI's device flow. OpenAgents
              keeps each account in an isolated app-server runtime and stores no access token
              in the account record.
            </p>
            <div class="admin-totals">
              <.badge variant={if(@codex_enabled, do: :success, else: :warning)}>
                {if(@codex_enabled, do: "DEVICE LOGIN READY", else: "DEVICE LOGIN DISABLED")}
              </.badge>
              <.badge variant={:info}>GPT-5.6 LUNA</.badge>
              <.badge variant={:dim}>NONE OR LOW REASONING</.badge>
            </div>
          </header>

          <.alert :if={!@codex_enabled} id="codex-disabled" variant={:warning}>
            This deployment has not enabled the Codex account runtime.
          </.alert>

          <.alert
            :if={@codex_enabled && is_binary(@interrupted_attempt_id)}
            id="codex-login-interrupted"
            variant={:warning}
          >
            <div class="space-y-3">
              <p>
                The previous SCV Codex login process is no longer reachable. No credential was
                stored.
              </p>
              <.button
                id="clear-interrupted-codex-login"
                variant={:secondary}
                phx-click="cancel_login"
              >
                CLEAR AND RETRY
              </.button>
            </div>
          </.alert>

          <section
            :if={@codex_enabled && is_nil(@pending) && is_nil(@interrupted_attempt_id)}
            aria-labelledby="connect-codex-heading"
          >
            <.card id="connect-codex-account">
              <div class="space-y-5">
                <div class="max-w-2xl space-y-2">
                  <h2 id="connect-codex-heading" class="card-title">Connect an individual account</h2>
                  <p class="text-muted-foreground">
                    Device login must be enabled in your ChatGPT security settings or by your
                    workspace administrator.
                  </p>
                </div>

                <.form
                  for={@form}
                  id="codex-account-form"
                  phx-submit="connect_account"
                  class="flex max-w-2xl flex-col gap-4 sm:flex-row sm:items-end"
                >
                  <.field class="min-w-0 flex-1">
                    <.label for={@form[:label].id}>Account label</.label>
                    <.input
                      field={@form[:label]}
                      type="text"
                      maxlength="80"
                      placeholder="Primary operator account"
                      autocomplete="off"
                    />
                  </.field>
                  <.button id="connect-codex-submit" type="submit" variant={:primary}>
                    CONNECT CODEX
                  </.button>
                </.form>
              </div>
            </.card>
          </section>

          <section :if={@pending} id="codex-device-login" aria-labelledby="device-login-heading">
            <.card>
              <div class="space-y-6">
                <div class="max-w-2xl space-y-2">
                  <div class="flex flex-wrap items-center gap-3">
                    <h2 id="device-login-heading" class="card-title">Finish on OpenAI</h2>
                    <.badge variant={:warning}>WAITING FOR YOU</.badge>
                  </div>
                  <p class="text-muted-foreground">
                    Continue only if you started this Codex connection from this screen. Cancel
                    if another site or person supplied the code.
                  </p>
                </div>

                <div class="flex flex-col gap-4 sm:flex-row sm:items-center">
                  <div>
                    <span class="block text-sm text-muted-foreground">One-time code</span>
                    <code
                      id="codex-device-code"
                      class="mt-1 block select-all text-2xl font-semibold tracking-[0.12em] text-foreground"
                    >{@pending.user_code}</code>
                  </div>
                  <.button
                    id="open-codex-device-login"
                    variant={:primary}
                    href={@pending.verification_url}
                    target="_blank"
                    rel="noopener noreferrer"
                  >
                    OPEN OPENAI DEVICE LOGIN <.icon name="external-link" />
                  </.button>
                </div>

                <p class="text-sm text-muted-foreground">
                  This code expires at {format_timestamp(@pending.expires_at)}.
                </p>

                <.button id="cancel-codex-login" variant={:secondary} phx-click="cancel_login">
                  CANCEL
                </.button>
              </div>
            </.card>
          </section>

          <section aria-labelledby="connected-codex-heading" class="space-y-4">
            <div class="max-w-2xl space-y-2">
              <h2 id="connected-codex-heading">Connected accounts</h2>
              <p class="text-muted-foreground">
                Each row represents one SCV driver identity and one private Codex credential
                generation.
              </p>
            </div>

            <.empty :if={@accounts == []} id="codex-accounts-empty" title="No Codex accounts">
              Connect an individual operator account to create the first SCV driver identity.
            </.empty>

            <ol :if={@accounts != []} id="codex-accounts" class="admin-rows">
              <li :for={account <- @accounts} id={"codex-account-#{account.id}"}>
                <.card>
                  <div class="admin-row">
                    <div class="admin-identity">
                      <span>
                        <strong>{account.label}</strong>
                        <span>{account.account_email || "Account identity pending"}</span>
                      </span>
                    </div>

                    <div class="admin-state">
                      <.badge variant={status_variant(account.status)}>
                        {String.upcase(account.status)}
                      </.badge>
                      <.badge :if={account.plan_type} variant={:dim}>
                        {String.upcase(account.plan_type)}
                      </.badge>
                    </div>

                    <dl class="admin-meta">
                      <div>
                        <dt>Driver</dt>
                        <dd>Codex app-server</dd>
                      </div>
                      <div>
                        <dt>Model</dt>
                        <dd>{model_label(account.available_models)}</dd>
                      </div>
                      <div>
                        <dt>Reasoning</dt>
                        <dd>{reasoning_label(account.reasoning_efforts)}</dd>
                      </div>
                      <div>
                        <dt>Credential generation</dt>
                        <dd>{account.credential_version || "—"}</dd>
                      </div>
                      <div>
                        <dt>Verified</dt>
                        <dd>{format_timestamp(account.last_verified_at)}</dd>
                      </div>
                    </dl>
                  </div>
                </.card>
              </li>
            </ol>
          </section>

          <.alert id="codex-service-accounts-later" variant={:info}>
            <strong>Service accounts come second.</strong> OpenAgents will add them after the
            individual operator flow passes qualification. OpenAI makes service accounts
            available only on pay-as-you-go plans.
          </.alert>
        </section>
      </main>
    </Layouts.app>
    """
  end

  defp status_variant("ready"), do: :success
  defp status_variant("pending"), do: :warning
  defp status_variant("failed"), do: :danger
  defp status_variant(_status), do: :dim

  defp model_label(models) do
    if "gpt-5.6-luna" in models, do: "gpt-5.6-luna", else: "Pending"
  end

  defp reasoning_label([]), do: "Pending"
  defp reasoning_label(efforts), do: Enum.join(efforts, " or ")

  defp format_timestamp(nil), do: "—"

  defp format_timestamp(%DateTime{} = at) do
    at
    |> DateTime.truncate(:second)
    |> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
  end

  defp error_message(:account_capacity_reached),
    do: "All configured Codex account slots are occupied."

  defp error_message(:codex_not_enabled), do: "Codex account connections are disabled."
  defp error_message(:login_not_found), do: "No pending Codex login was found."
  defp error_message(:login_not_running), do: "The pending Codex login is no longer running."
  defp error_message(:required_model_unavailable), do: "This account cannot use gpt-5.6-luna."

  defp error_message(reason) when is_binary(reason),
    do: "Codex account connection failed with code #{reason}."

  defp error_message(_reason), do: "Codex account connection failed."
end