10.2 KiB · text History b868907
defmodule GitGudWeb.AuditLive.Page do
  @moduledoc """
  What the audit views share: the sudo check and canonical paths in
  `handle_params`, a page of entries, and the page body — category
  tabs, the actor and repository filter, the entries, and Older/Newer
  links.

  New entries arrive live: once connected, a view subscribes to its
  scope's topic (`GitGud.Audit.topic/1`), and `live/3` puts each one
  that matches the filters at the top of the first page. Older pages
  stay as they were.

  An actor filter names a handle: the account's entries if it exists,
  and otherwise the ones recorded under that label, so a deleted
  account's history can still be picked out. A repository filter does
  the same with the org's `handle/name`.

  Each view keeps its own permission check (run on every
  `handle_params`, before `params/5`) and its own chrome around
  `body/1`.
  """
  use GitGudWeb, :html

  import GitGudWeb.AuditComponents

  import Phoenix.LiveView,
    only: [put_flash: 3, redirect: 2, push_patch: 2, stream: 4, stream_insert: 4, connected?: 1]

  alias GitGud.Accounts
  alias GitGud.Audit
  alias GitGudWeb.AuditLive.Filters

  @per_page 50

  @doc """
  The `handle_params` of a view whose user may see `scope`'s entries
  under `base`: sends them to re-authenticate outside sudo mode,
  patches an unknown or differently spelled filter to its canonical
  path, and otherwise loads the page.
  """
  def params(socket, params, uri, base, scope) do
    path = URI.parse(uri).path

    with true <- Accounts.sudo_mode?(socket.assigns.current_scope.user) || :reauth,
         {:ok, filters} <-
           Filters.parse(params, socket.assigns.categories, socket.assigns.refines) do
      canonical = Filters.to_path(base, filters)

      if path == canonical,
        do: {:noreply, socket |> subscribe(scope) |> load(base, scope, filters)},
        else: {:noreply, push_patch(socket, to: canonical, replace: true)}
    else
      :reauth ->
        {:noreply,
         socket
         |> put_flash(:error, "You must re-authenticate to access this page.")
         |> redirect(to: ~p"/users/log-in?#{[return_to: path]}")}

      :error ->
        {:noreply,
         socket
         |> put_flash(:error, "That filter isn't one this page knows.")
         |> push_patch(to: base)}
    end
  end

  @doc "The categories, of `wanted`, that some catalog action belongs to."
  def categories(wanted), do: Enum.filter(wanted, &(&1 in GitGud.Audit.Catalog.categories()))

  @doc """
  Ready a mounted socket for `params/5`: the categories, which
  refinements the view offers, and the stream. Options:

    * `refines:` — of `:actor`, `:org`, `:repo`, `:scope` (default
      `[:actor]`)
    * `repos:` — the repository names the filter offers, with `:repo`
    * `show_request:` — show each entry's address and user agent; the
      instance view's alone
    * `own_requests:` — a user id: show the address and user agent of
      that user's own entries only
  """
  def setup(socket, categories, opts \\ []) do
    socket
    |> assign(:categories, categories(categories))
    |> assign(:refines, Keyword.get(opts, :refines, [:actor]))
    |> assign(:repo_options, Keyword.get(opts, :repos))
    |> assign(:show_request, Keyword.get(opts, :show_request, false))
    |> assign(:own_requests, Keyword.get(opts, :own_requests))
    |> Phoenix.LiveView.stream_configure(:entries, dom_id: &"audit-entry-#{&1.id}")
  end

  @doc """
  A `{:audit, id}` broadcast arrived. `allowed?` is the view's own
  permission check, asked again — a page left open mustn't keep
  showing new entries to someone who has since lost the right, or
  whose sudo window closed. Then, on the first page only, the entry
  goes at the top if it matches the filters.
  """
  def live(socket, id, allowed?) do
    user = socket.assigns.current_scope.user

    cond do
      not (allowed? and Accounts.sudo_mode?(user)) ->
        {:noreply, redirect(socket, to: socket.assigns.base)}

      socket.assigns.filters.cursor != nil ->
        {:noreply, socket}

      true ->
        %{scope: scope, filters: filters} = socket.assigns
        matching = Map.put(list_filters(scope, filters), :id, id)

        case Audit.list(scope, matching, nil, 1) do
          {[entry], _} ->
            {:noreply, stream_insert(socket, :entries, entry, at: 0, limit: @per_page)}

          _ ->
            {:noreply, socket}
        end
    end
  end

  defp subscribe(%{assigns: %{subscribed: true}} = socket, _scope), do: socket

  defp subscribe(socket, scope) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(GitGud.PubSub, Audit.topic(scope))
      assign(socket, :subscribed, true)
    else
      socket
    end
  end

  @doc "The filter form's submit: patch to the path for what it says."
  def filter(socket, params) do
    case Filters.from_form(socket.assigns.filters, params) do
      {:ok, filters} ->
        {:noreply, push_patch(socket, to: Filters.to_path(socket.assigns.base, filters))}

      :error ->
        {:noreply, put_flash(socket, :error, "That isn't a handle or repository name.")}
    end
  end

  defp load(socket, base, scope, filters) do
    {entries, cursors} =
      Audit.list(scope, list_filters(scope, filters), filters.cursor, @per_page)

    socket
    |> assign(:base, base)
    |> assign(:scope, scope)
    |> assign(:filters, filters)
    |> assign(
      :filter_form,
      to_form(
        %{
          "actor" => filters.actor || "",
          "org" => filters.org || "",
          "repo" => filters.repo || "",
          "scope" => filters.scope == "instance"
        },
        as: :filter
      )
    )
    |> assign(
      :older,
      cursors.older && Filters.to_path(base, %{filters | cursor: {:before, cursors.older}})
    )
    |> assign(:newer, newer_path(base, filters, cursors.newer))
    |> stream(:entries, entries, reset: true)
  end

  defp list_filters(scope, filters) do
    %{category: filters.category}
    |> Map.merge(Audit.actor_filter(filters.actor))
    |> Map.merge(Audit.organization_filter(filters.org))
    |> Map.merge(repo_filter(scope, filters.repo))
    |> Map.merge(if filters.scope == "instance", do: %{instance_only: true}, else: %{})
  end

  defp repo_filter({:org, org}, name), do: Audit.repository_filter(org, name)
  defp repo_filter(_scope, _name), do: %{}

  # Back to the top when the newer page would be the first one anyway.
  defp newer_path(_base, %Filters{cursor: nil}, _), do: nil
  defp newer_path(base, filters, nil), do: Filters.to_path(base, %{filters | cursor: nil})
  defp newer_path(base, filters, id), do: Filters.to_path(base, %{filters | cursor: {:after, id}})

  @doc "Category tabs, the filter, the entries, and the pager."
  attr :base, :string, required: true
  attr :filters, Filters, required: true
  attr :filter_form, Phoenix.HTML.Form, required: true
  attr :categories, :list, required: true
  attr :refines, :list, default: [:actor]
  attr :repo_options, :list, default: nil
  attr :show_request, :boolean, default: false
  attr :own_requests, :integer, default: nil
  attr :entries, :any, required: true
  attr :older, :string, default: nil
  attr :newer, :string, default: nil

  def body(assigns) do
    ~H"""
    <nav id="audit-categories" class="flex flex-wrap gap-1" aria-label="Category">
      <.link
        id="audit-category-all"
        patch={tab(@base, @filters, nil)}
        class={["btn btn-xs", if(@filters.category == nil, do: "btn-primary", else: "btn-ghost")]}
      >
        All
      </.link>
      <.link
        :for={c <- @categories}
        id={"audit-category-#{c}"}
        patch={tab(@base, @filters, c)}
        class={["btn btn-xs", if(@filters.category == c, do: "btn-primary", else: "btn-ghost")]}
      >
        {category_label(c)}
      </.link>
    </nav>

    <.form
      :if={@refines != []}
      for={@filter_form}
      id="audit-filter"
      phx-submit="filter"
      class="flex flex-wrap items-end gap-2"
    >
      <div class="w-48">
        <.input field={@filter_form[:actor]} label="Who" placeholder="handle" />
      </div>
      <div :if={:org in @refines} class="w-48">
        <.input field={@filter_form[:org]} label="Organization" placeholder="handle" />
      </div>
      <div :if={:repo in @refines and @repo_options} class="w-48">
        <.input
          field={@filter_form[:repo]}
          type="select"
          label="Repository"
          prompt="Any"
          options={@repo_options}
        />
      </div>
      <div :if={:scope in @refines} class="mb-2">
        <.input
          field={@filter_form[:scope]}
          type="checkbox"
          label="Instance-wide only"
        />
      </div>
      <button type="submit" class="btn btn-sm mb-2">Filter</button>
      <.link
        :if={refined?(@filters)}
        id="audit-filter-clear"
        patch={
          Filters.to_path(@base, %{
            @filters
            | actor: nil,
              org: nil,
              repo: nil,
              scope: nil,
              cursor: nil
          })
        }
        class="btn btn-sm btn-ghost mb-2"
      >
        Clear
      </.link>
    </.form>

    <ul id="audit-entries" phx-update="stream" class="divide-y divide-base-300">
      <li id="audit-empty" class="hidden only:block py-8 text-center text-sm opacity-60">
        {if refined?(@filters),
          do: "Nothing matches that filter.",
          else: "Nothing recorded here yet."}
      </li>
      <.entry_row
        :for={{id, entry} <- @entries}
        id={id}
        entry={entry}
        show_request={@show_request}
        own_requests={@own_requests}
      />
    </ul>

    <nav :if={@newer || @older} class="flex justify-between text-sm" aria-label="Pages">
      <.link :if={@newer} id="audit-newer" patch={@newer} class="btn btn-sm btn-ghost">
        <.icon name="hero-arrow-left-micro" class="size-4" /> Newer
      </.link>
      <span :if={!@newer}></span>
      <.link :if={@older} id="audit-older" patch={@older} class="btn btn-sm btn-ghost">
        Older <.icon name="hero-arrow-right-micro" class="size-4" />
      </.link>
    </nav>
    """
  end

  defp refined?(filters), do: !!(filters.actor || filters.org || filters.repo || filters.scope)

  # A category tab keeps the actor and repository, and starts over at
  # the newest entry.
  defp tab(base, filters, category),
    do: Filters.to_path(base, %{filters | category: category, cursor: nil})
end