7.1 KiB · text History ec2ea47
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)}"

      "repo.created" ->
        repo_created(entry.metadata)

      "repo.forked" ->
        "forked it to #{entry.metadata["destination"]}"

      "repo.deleted" ->
        "deleted the repository"

      "repo.branch_overwritten" ->
        "overwrote from upstream the branch"

      "repo.settings_updated" ->
        "updated the settings (#{keys(entry.metadata)})"

      "repo.visibility_changed" ->
        "changed visibility #{from_to(entry.metadata)}"

      "repo.archived" ->
        "archived the repository"

      "repo.unarchived" ->
        "unarchived the repository"

      "repo.upstream_settings_changed" ->
        upstream_change(entry.metadata)

      "repo.interaction_policy_changed" ->
        "changed the interaction policy #{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 repo_created(%{"forked_from" => from}) when is_binary(from),
    do: "forked the repository from #{from}"

  defp repo_created(%{"imported_from_host" => host}) when is_binary(host),
    do: "imported the repository from #{host}"

  defp repo_created(metadata), do: "created the repository (#{metadata["visibility"]})"

  defp upstream_change(metadata) do
    auto = if metadata["auto"], do: ", syncing automatically", else: ""
    "set upstream sync to #{metadata["mode"]}#{auto}"
  end

  defp keys(metadata), do: metadata |> Map.keys() |> Enum.sort() |> Enum.join(", ")

  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