lib/openagents_web/live/admin_live.ex

main at 436d37facc96 · 5 KB

defmodule OpenAgentsWeb.AdminLive do
  @moduledoc """
  The operator surface: every account, newest first, with what each has done here.

  Read-only by construction. `OpenAgents.Admin` exposes no write, so nothing
  here can ban an account, alter a conversation, or change configuration
  (`INVARIANTS.md` ADMIN-001). The only action is paging.

  This lists accounts and activity counts, never content. Knowing that someone
  sent forty messages is an operational fact; reading them is a different
  decision with a different implementation, and there is no route to it from
  here.

  It replaced a voice-call recordings panel. Recording was off, the product no
  longer captures call audio, and an operator surface whose only view is of a
  capability that does not run is worse than no surface: it describes the
  system inaccurately to the one person who most needs an accurate picture.
  """

  use OpenAgentsWeb, :live_view

  alias OpenAgents.Accounts
  alias OpenAgents.Admin

  @page_size 50

  @impl true
  def mount(_params, _session, socket) do
    if Accounts.admin?(socket.assigns.current_user) do
      {:ok,
       socket
       |> assign(:page_title, "Operator · OpenAgents")
       |> assign(:offset, 0)
       |> load_page()}
    else
      {:ok, redirect(socket, to: ~p"/")}
    end
  end

  @impl true
  def handle_event(event, _params, socket) when event in ["next_page", "previous_page"] do
    # Re-checked per event, not only at mount: a long-lived socket outlives the
    # decision that opened it.
    if Accounts.admin?(socket.assigns.current_user) do
      do_handle_event(event, socket)
    else
      {:noreply, redirect(socket, to: ~p"/")}
    end
  end

  defp do_handle_event("next_page", socket) do
    offset = socket.assigns.offset + @page_size

    if offset < socket.assigns.total,
      do: {:noreply, socket |> assign(:offset, offset) |> load_page()},
      else: {:noreply, socket}
  end

  defp do_handle_event("previous_page", socket) do
    offset = max(socket.assigns.offset - @page_size, 0)
    {:noreply, socket |> assign(:offset, offset) |> load_page()}
  end

  defp load_page(socket) do
    socket
    |> assign(:total, Admin.count_accounts())
    |> assign(:accounts, Admin.list_accounts(limit: @page_size, offset: socket.assigns.offset))
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title="Admin"
      wide
    >
      <section class="panel" aria-label="Accounts">
        <header class="panel__header">
          <h1 class="panel__title">Accounts</h1>
          <span class="admin-count">{@total} total</span>
        </header>

        <.empty :if={@accounts == []} id="admin-empty" title="No accounts yet">
          Accounts appear here after their first sign-in.
        </.empty>

        <div :if={@accounts != []} class="admin-table-scroll">
          <table id="admin-accounts" class="admin-table">
            <thead>
              <tr>
                <th scope="col">Account</th>
                <th scope="col">Joined</th>
                <th scope="col">Last seen</th>
                <th scope="col" class="admin-table__number">Messages</th>
                <th scope="col" class="admin-table__number">Issues</th>
                <th scope="col">Status</th>
              </tr>
            </thead>
            <tbody>
              <tr :for={account <- @accounts} id={"admin-account-#{account.id}"}>
                <td>
                  <div class="admin-identity">
                    <.avatar
                      src={account.github_avatar_url}
                      alt=""
                      size={:sm}
                      fallback={String.first(account.github_login)}
                    />
                    <span class="admin-identity__names">
                      <strong :if={account.github_name}>{account.github_name}</strong>
                      <span>@{account.github_login}</span>
                    </span>
                  </div>
                </td>
                <td>{date(account.joined_at)}</td>
                <td>{date(account.last_authenticated_at)}</td>
                <td class="admin-table__number">{account.message_count}</td>
                <td class="admin-table__number">{account.issue_count}</td>
                <td>
                  <.badge variant={status_variant(account.status)}>
                    {String.upcase(account.status)}
                  </.badge>
                </td>
              </tr>
            </tbody>
          </table>
        </div>

        <footer :if={@total > page_size()} class="admin-paging">
          <.button
            id="previous-page"
            variant={:secondary}
            size={:sm}
            phx-click="previous_page"
            disabled={@offset == 0}
          >
            Previous
          </.button>
          <span class="admin-paging__position">
            {@offset + 1}–{min(@offset + page_size(), @total)} of {@total}
          </span>
          <.button
            id="next-page"
            variant={:secondary}
            size={:sm}
            phx-click="next_page"
            disabled={@offset + page_size() >= @total}
          >
            Next
          </.button>
        </footer>
      </section>
    </Layouts.app>
    """
  end

  # `@page_size` inside ~H would be an assign lookup, not this attribute.
  defp page_size, do: @page_size

  # A date, not a timestamp. An operator scanning a list wants to know roughly
  # when, and to-the-second precision in a column invites arithmetic nobody
  # asked for.
  defp date(nil), do: "—"
  defp date(%DateTime{} = at), do: at |> DateTime.to_date() |> Date.to_iso8601()

  defp date(%NaiveDateTime{} = at),
    do: at |> NaiveDateTime.to_date() |> Date.to_iso8601()

  defp status_variant("active"), do: :success
  defp status_variant("banned"), do: :danger
  defp status_variant(_status), do: :default
end