5.6 KiB · text History 85accc3
defmodule GitGudWeb.AuditComponents do
  @moduledoc """
  One audit entry as a row: when, who, what, and where.

  `describe/1` has a clause per `GitGud.Audit.Catalog` action; one it
  doesn't know yet shows as the raw action rather than breaking the
  page, so recording can land before its wording does.
  """
  use GitGudWeb, :html

  import GitGudWeb.DashboardComponents, only: [ago: 1]

  alias GitGud.Audit.Entry

  @doc "The action as a phrase, without its actor."
  @spec describe(Entry.t()) :: String.t()
  def describe(%Entry{action: action} = entry) do
    case action do
      "repo_secret.set" -> "set the secret"
      "repo_secret.deleted" -> "deleted the secret"
      "repo_variable.set" -> "set the variable"
      "repo_variable.deleted" -> "deleted the variable"
      "org_secret.set" -> "set the organization secret"
      "org_secret.deleted" -> "deleted the organization secret"
      "org_variable.set" -> "set the organization variable"
      "org_variable.deleted" -> "deleted the organization variable"
      "org.invitation_sent" -> "invited #{entry.metadata["role"] || "member"}"
      "org.invitation_withdrawn" -> "withdrew the invitation for"
      "org.invitation_accepted" -> "accepted the invitation as #{entry.metadata["role"]}"
      "org.invitation_declined" -> "declined the invitation"
      "org.member_role_changed" -> role_change(entry.metadata)
      "org.member_removed" -> "removed #{entry.metadata["role"] || "member"}"
      "team.created" -> "created the team"
      "org.created" -> "created the organization (#{entry.metadata["visibility"]})"
      "org.profile_updated" -> profile_change(entry.metadata)
      "org.visibility_changed" -> "changed visibility #{from_to(entry.metadata)}"
      "audit.pruned" -> "pruned #{entry.metadata["count"]} audit entries"
      other -> other
    end
  end

  defp role_change(metadata), do: "changed the role #{from_to(metadata)} of"

  defp profile_change(%{"fields" => [_ | _] = fields}),
    do: "updated the profile (#{Enum.join(fields, ", ")})"

  defp profile_change(_), do: "updated the profile"

  defp from_to(metadata), do: "(#{metadata["from"]} → #{metadata["to"]})"

  @doc "Category tab label."
  def category_label("secrets"), do: "Secrets & variables"
  def category_label("members"), do: "Members"
  def category_label("settings"), do: "Settings"
  def category_label("repositories"), do: "Repositories"
  def category_label("branch_protection"), do: "Branch protection"
  def category_label("deploy_keys"), do: "Deploy keys"
  def category_label("webhooks"), do: "Webhooks"
  def category_label("credentials"), do: "Tokens"
  def category_label("runners"), do: "Runners"
  def category_label("ci"), do: "CI"
  def category_label("moderation"), do: "Moderation"
  def category_label("instance"), do: "Instance"
  def category_label("account"), do: "Account"
  def category_label(other), do: other

  @doc """
  An entry's row. `show_request` adds the client address and user
  agent, which only the instance view shows.
  """
  attr :id, :string, required: true
  attr :entry, Entry, required: true
  attr :show_request, :boolean, default: false

  def entry_row(assigns) do
    ~H"""
    <li id={@id} class="flex items-start gap-3 py-3 text-sm">
      <span
        class="w-24 shrink-0 text-xs opacity-60 tabular-nums"
        title={DateTime.to_iso8601(@entry.inserted_at)}
      >
        {ago(@entry.inserted_at)}
      </span>
      <div class="min-w-0 flex-1 space-y-1">
        <p>
          <.actor entry={@entry} />
          <span class="opacity-80">{describe(@entry)}</span>
          <code :if={@entry.target_label} class="font-mono text-xs bg-base-200 rounded px-1">
            {@entry.target_label}
          </code>
          <span :if={@entry.repository_label} class="opacity-70">
            in <.place entry={@entry} />
          </span>
        </p>
        <details :if={@entry.metadata != %{} or @show_request} class="text-xs opacity-70">
          <summary class="cursor-pointer select-none">Details</summary>
          <dl class="mt-1 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-0.5 font-mono">
            <%= for {key, value} <- Enum.sort(@entry.metadata) do %>
              <dt>{key}</dt>
              <dd class="break-all">{render_value(value)}</dd>
            <% end %>
            <%= if @show_request do %>
              <dt>ip</dt>
              <dd>{@entry.ip || "—"}</dd>
              <dt>user agent</dt>
              <dd class="break-all">{@entry.user_agent || "—"}</dd>
              <dt>via</dt>
              <dd>{@entry.via || "—"}</dd>
            <% end %>
          </dl>
        </details>
      </div>
    </li>
    """
  end

  attr :entry, Entry, required: true

  defp actor(%{entry: %Entry{actor_type: "user", actor_id: id}} = assigns) when not is_nil(id) do
    ~H"""
    <.link navigate={~p"/u/#{@entry.actor_label}"} class="font-medium link link-hover">
      {@entry.actor_label}
    </.link>
    """
  end

  defp actor(assigns) do
    ~H"""
    <span class="font-medium">{@entry.actor_label}</span>
    """
  end

  attr :entry, Entry, required: true

  # A link while the repository still exists; its label once it's gone.
  defp place(%{entry: %Entry{repository_id: id}} = assigns) when not is_nil(id) do
    ~H"""
    <.link navigate={"/r/" <> @entry.repository_label} class="link link-hover font-mono">
      {@entry.repository_label}
    </.link>
    """
  end

  defp place(assigns) do
    ~H"""
    <span class="font-mono line-through decoration-1">{@entry.repository_label}</span>
    """
  end

  defp render_value(v) when is_list(v), do: Enum.map_join(v, ", ", &to_string/1)
  defp render_value(nil), do: "—"
  defp render_value(v), do: to_string(v)
end