neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Give orgs and repos their own moderation pages
500bdf9 · Gabriel Morell · 2026-09-10 16:13
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/reports.ex
+5
−0
@@ -301,6 +301,7 @@ defmodule GitGud.Reports do
| 301 | 301 | excerpt: string, # body snippet (≤ 200 chars) |
| 302 | 302 | url: string | nil, # link to view the content |
| 303 | 303 | author: string, # reporter / commenter display name |
| 304 | + author_id: integer | nil, | |
| 304 | 305 | kind: atom, # echoes the target type as an atom |
| 305 | 306 | moderatable?: boolean, # supports replace-with-mod-message |
| 306 | 307 | moderated?: boolean, # already replaced (admin can restore) |
@@ -326,6 +327,7 @@ defmodule GitGud.Reports do
| 326 | 327 | excerpt: excerpt(issue.original_body || issue.body), |
| 327 | 328 | url: repo && "/r/#{handle(repo)}/#{repo.name}/issues/#{issue.number}", |
| 328 | 329 | author: user_name(author), |
| 330 | + author_id: author && author.id, | |
| 329 | 331 | moderatable?: true, |
| 330 | 332 | moderated?: GitGud.Issues.Issue.moderated?(issue), |
| 331 | 333 | suspendable_actor_id: nil |
@@ -350,6 +352,7 @@ defmodule GitGud.Reports do
| 350 | 352 | excerpt: excerpt(comment.original_body || comment.body), |
| 351 | 353 | url: repo && issue && "/r/#{handle(repo)}/#{repo.name}/issues/#{issue.number}", |
| 352 | 354 | author: user_name(author), |
| 355 | + author_id: author && author.id, | |
| 353 | 356 | moderatable?: true, |
| 354 | 357 | moderated?: GitGud.Issues.IssueComment.moderated?(comment), |
| 355 | 358 | suspendable_actor_id: nil |
@@ -372,6 +375,7 @@ defmodule GitGud.Reports do
| 372 | 375 | title: "##{pr.number} #{pr.title}", |
| 373 | 376 | excerpt: excerpt(pr.original_body || pr.body), |
| 374 | 377 | url: repo && "/r/#{handle(repo)}/#{repo.name}/pulls/#{pr.number}", |
| 378 | + author_id: author && author.id, | |
| 375 | 379 | author: |
| 376 | 380 | cond do |
| 377 | 381 | author -> user_name(author) |
@@ -403,6 +407,7 @@ defmodule GitGud.Reports do
| 403 | 407 | title: "comment on ##{pr && pr.number} #{(pr && pr.title) || "(deleted PR)"}", |
| 404 | 408 | excerpt: excerpt(comment.original_body || comment.body), |
| 405 | 409 | url: repo && pr && "/r/#{handle(repo)}/#{repo.name}/pulls/#{pr.number}", |
| 410 | + author_id: author && author.id, | |
| 406 | 411 | author: |
| 407 | 412 | cond do |
| 408 | 413 | author -> user_name(author) |
modified
lib/git_gud_web/components/core_components.ex
+17
−7
@@ -570,6 +570,11 @@ defmodule GitGudWeb.CoreComponents do
| 570 | 570 | label="Interaction policy" |
| 571 | 571 | active={@current == :interaction} |
| 572 | 572 | /> |
| 573 | + <.repo_settings_nav_link | |
| 574 | + navigate={~p"/r/#{@handle}/#{@repo.name}/settings/moderation"} | |
| 575 | + label="Moderation" | |
| 576 | + active={@current == :moderation} | |
| 577 | + /> | |
| 573 | 578 | </nav> |
| 574 | 579 | </header> |
| 575 | 580 | """ |
@@ -635,13 +640,16 @@ defmodule GitGudWeb.CoreComponents do
| 635 | 640 | <input type="radio" name={@tabs_name} role="tab" class="tab" aria-label="Native" checked /> |
| 636 | 641 | <div role="tabpanel" class="tab-content p-3 space-y-2"> |
| 637 | 642 | <p class="text-xs opacity-70"> |
| 638 | − Run on a host with <code>forgejo-runner</code> on $PATH. See the | |
| 643 | + Run on a host with <code>forgejo-runner</code> | |
| 644 | + on $PATH. See the | |
| 639 | 645 | <a |
| 640 | 646 | class="link" |
| 641 | 647 | href="https://forgejo.org/docs/latest/admin/actions/installation/binary/" |
| 642 | 648 | target="_blank" |
| 643 | 649 | rel="noopener" |
| 644 | − >binary install docs</a> | |
| 650 | + > | |
| 651 | + binary install docs | |
| 652 | + </a> | |
| 645 | 653 | for the systemd unit. |
| 646 | 654 | </p> |
| 647 | 655 | <pre class="text-xs bg-base-200 p-3 rounded overflow-x-auto" phx-no-curly-interpolation><%= @native_cmd %></pre> |
@@ -651,8 +659,8 @@ defmodule GitGudWeb.CoreComponents do
| 651 | 659 | <div role="tabpanel" class="tab-content p-3 space-y-2"> |
| 652 | 660 | <p class="text-xs opacity-70"> |
| 653 | 661 | Mounts the host's Docker socket so the runner can spawn job containers. |
| 654 | − For a dind-isolated <code>docker compose</code> variant, see the | |
| 655 | − <a | |
| 662 | + For a dind-isolated <code>docker compose</code> | |
| 663 | + variant, see the <a | |
| 656 | 664 | class="link" |
| 657 | 665 | href="https://forgejo.org/docs/latest/admin/actions/installation/docker/" |
| 658 | 666 | target="_blank" |
@@ -666,7 +674,8 @@ defmodule GitGudWeb.CoreComponents do
| 666 | 674 | <div role="tabpanel" class="tab-content p-3 space-y-2"> |
| 667 | 675 | <p class="text-xs opacity-70"> |
| 668 | 676 | Rootless Podman. Enable user lingering (<code>loginctl enable-linger <user></code>) |
| 669 | − and ensure the Podman socket is reachable at <code>$XDG_RUNTIME_DIR/podman/podman.sock</code> | |
| 677 | + and ensure the Podman socket is reachable at | |
| 678 | + <code>$XDG_RUNTIME_DIR/podman/podman.sock</code> | |
| 670 | 679 | (start it with <code>systemctl --user enable --now podman.socket</code>). |
| 671 | 680 | </p> |
| 672 | 681 | <pre class="text-xs bg-base-200 p-3 rounded overflow-x-auto" phx-no-curly-interpolation><%= @podman_cmd %></pre> |
@@ -676,8 +685,9 @@ defmodule GitGudWeb.CoreComponents do
| 676 | 685 | <div role="tabpanel" class="tab-content p-3 space-y-2"> |
| 677 | 686 | <p class="text-xs opacity-70"> |
| 678 | 687 | Podman Quadlet (<code>systemd</code>-managed rootless container). |
| 679 | − Drop the <code>.container</code> file in | |
| 680 | − <code>~/.config/containers/systemd/</code> and reload the user units. | |
| 688 | + Drop the <code>.container</code> | |
| 689 | + file in <code>~/.config/containers/systemd/</code> | |
| 690 | + and reload the user units. | |
| 681 | 691 | </p> |
| 682 | 692 | <pre class="text-xs bg-base-200 p-3 rounded overflow-x-auto" phx-no-curly-interpolation><%= @quadlet_cmd %></pre> |
| 683 | 693 | </div> |
added
lib/git_gud_web/components/moderation_admin_components.ex
+214
−0
@@ -0,0 +1,214 @@
| 1 | +defmodule GitGudWeb.ModerationAdminComponents do | |
| 2 | + @moduledoc """ | |
| 3 | + The report queue and ban list shared by the org and repo moderation | |
| 4 | + pages. | |
| 5 | + | |
| 6 | + One set of components rather than two near-identical pages, so the | |
| 7 | + two scopes can't drift apart in what they let a moderator do. | |
| 8 | + | |
| 9 | + The parent LiveView owns every action: `dismiss_report`, | |
| 10 | + `action_report`, `moderate_report`, `ban_user` and `lift_ban`. | |
| 11 | + """ | |
| 12 | + use GitGudWeb, :html | |
| 13 | + | |
| 14 | + alias GitGud.Moderation.Ban | |
| 15 | + | |
| 16 | + @doc "Open reports in this scope, each with a preview of what was reported." | |
| 17 | + attr :reports, :list, required: true | |
| 18 | + attr :previews, :map, required: true, doc: "report id => Reports.preview/1 result" | |
| 19 | + | |
| 20 | + def report_queue(assigns) do | |
| 21 | + ~H""" | |
| 22 | + <p :if={@reports == []} class="py-8 text-center text-sm opacity-60"> | |
| 23 | + Nothing reported here. | |
| 24 | + </p> | |
| 25 | + | |
| 26 | + <ul :if={@reports != []} class="space-y-3"> | |
| 27 | + <li | |
| 28 | + :for={report <- @reports} | |
| 29 | + id={"report-#{report.id}"} | |
| 30 | + class="rounded-lg border border-base-300 p-3" | |
| 31 | + > | |
| 32 | + <div class="flex items-baseline gap-2 flex-wrap text-sm"> | |
| 33 | + <span class="badge badge-sm badge-warning">{report.reason}</span> | |
| 34 | + <span class="opacity-60 text-xs"> | |
| 35 | + reported by {reporter_name(report)} · {format_dt(report.inserted_at)} | |
| 36 | + </span> | |
| 37 | + </div> | |
| 38 | + | |
| 39 | + <p :if={report.details} class="text-sm mt-1 opacity-80">{report.details}</p> | |
| 40 | + | |
| 41 | + <.preview preview={@previews[report.id]} /> | |
| 42 | + | |
| 43 | + <div class="flex flex-wrap gap-2 mt-3"> | |
| 44 | + <button | |
| 45 | + type="button" | |
| 46 | + phx-click="dismiss_report" | |
| 47 | + phx-value-id={report.id} | |
| 48 | + class="btn btn-xs btn-ghost" | |
| 49 | + > | |
| 50 | + Dismiss | |
| 51 | + </button> | |
| 52 | + <button | |
| 53 | + type="button" | |
| 54 | + phx-click="action_report" | |
| 55 | + phx-value-id={report.id} | |
| 56 | + class="btn btn-xs btn-ghost" | |
| 57 | + > | |
| 58 | + Mark handled | |
| 59 | + </button> | |
| 60 | + <button | |
| 61 | + :if={moderatable?(@previews[report.id])} | |
| 62 | + type="button" | |
| 63 | + phx-click="moderate_report" | |
| 64 | + phx-value-id={report.id} | |
| 65 | + class="btn btn-xs btn-warning btn-outline" | |
| 66 | + data-confirm="Replace this content with a moderation message? The original stays visible to admins and its author." | |
| 67 | + > | |
| 68 | + Replace with a mod message | |
| 69 | + </button> | |
| 70 | + <button | |
| 71 | + :if={author_id(@previews[report.id])} | |
| 72 | + type="button" | |
| 73 | + phx-click="ban_reported_author" | |
| 74 | + phx-value-id={report.id} | |
| 75 | + class="btn btn-xs btn-error btn-outline" | |
| 76 | + data-confirm="Ban this author from writing here?" | |
| 77 | + > | |
| 78 | + Ban the author | |
| 79 | + </button> | |
| 80 | + </div> | |
| 81 | + </li> | |
| 82 | + </ul> | |
| 83 | + """ | |
| 84 | + end | |
| 85 | + | |
| 86 | + attr :preview, :any, default: nil | |
| 87 | + | |
| 88 | + defp preview(assigns) do | |
| 89 | + ~H""" | |
| 90 | + <div :if={is_map(@preview)} class="mt-2 rounded border border-base-300 bg-base-200/40 p-2"> | |
| 91 | + <p class="text-xs opacity-60"> | |
| 92 | + {@preview.kind} by {@preview.author} | |
| 93 | + <span :if={@preview.moderated?} class="badge badge-xs badge-ghost ml-1"> | |
| 94 | + already moderated | |
| 95 | + </span> | |
| 96 | + </p> | |
| 97 | + <.link :if={@preview.url} navigate={@preview.url} class="text-sm link link-hover"> | |
| 98 | + {@preview.title} | |
| 99 | + </.link> | |
| 100 | + <p :if={is_nil(@preview.url)} class="text-sm">{@preview.title}</p> | |
| 101 | + <p :if={@preview.excerpt} class="text-xs opacity-70 mt-1 whitespace-pre-wrap"> | |
| 102 | + {@preview.excerpt} | |
| 103 | + </p> | |
| 104 | + </div> | |
| 105 | + | |
| 106 | + <p :if={@preview == :missing} class="mt-2 text-xs opacity-60 italic"> | |
| 107 | + The reported content is gone. | |
| 108 | + </p> | |
| 109 | + """ | |
| 110 | + end | |
| 111 | + | |
| 112 | + @doc "Everyone banned in this scope, in force or not." | |
| 113 | + attr :bans, :list, required: true | |
| 114 | + attr :scope_label, :string, required: true | |
| 115 | + | |
| 116 | + def ban_list(assigns) do | |
| 117 | + ~H""" | |
| 118 | + <p :if={@bans == []} class="py-6 text-sm opacity-60"> | |
| 119 | + Nobody is banned from this {@scope_label}. | |
| 120 | + </p> | |
| 121 | + | |
| 122 | + <ul :if={@bans != []} class="divide-y divide-base-300"> | |
| 123 | + <li :for={ban <- @bans} id={"ban-#{ban.id}"} class="py-2 flex items-center gap-3 flex-wrap"> | |
| 124 | + <.avatar name={user_name(ban.banned_user)} size="sm" alt={user_name(ban.banned_user)} /> | |
| 125 | + <div class="flex-1 min-w-0"> | |
| 126 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 127 | + <span class="font-medium">{user_name(ban.banned_user)}</span> | |
| 128 | + <span class={[ | |
| 129 | + "badge badge-xs", | |
| 130 | + if(Ban.active?(ban), do: "badge-error", else: "badge-ghost") | |
| 131 | + ]}> | |
| 132 | + {ban_state(ban)} | |
| 133 | + </span> | |
| 134 | + </div> | |
| 135 | + <p class="text-xs opacity-60"> | |
| 136 | + <span :if={ban.reason}>{ban.reason} · </span> | |
| 137 | + banned by {user_name(ban.banned_by)} on {format_dt(ban.inserted_at)}<span :if={ | |
| 138 | + ban.expires_at | |
| 139 | + }>, until {format_dt(ban.expires_at)}</span> | |
| 140 | + </p> | |
| 141 | + </div> | |
| 142 | + <button | |
| 143 | + :if={Ban.active?(ban)} | |
| 144 | + type="button" | |
| 145 | + phx-click="lift_ban" | |
| 146 | + phx-value-id={ban.id} | |
| 147 | + class="btn btn-xs btn-ghost" | |
| 148 | + > | |
| 149 | + Lift | |
| 150 | + </button> | |
| 151 | + </li> | |
| 152 | + </ul> | |
| 153 | + """ | |
| 154 | + end | |
| 155 | + | |
| 156 | + @doc "The form for banning someone by handle." | |
| 157 | + attr :scope_label, :string, required: true | |
| 158 | + | |
| 159 | + def ban_form(assigns) do | |
| 160 | + ~H""" | |
| 161 | + <form phx-submit="ban_user" class="flex flex-wrap items-end gap-2"> | |
| 162 | + <label class="flex flex-col gap-1"> | |
| 163 | + <span class="text-xs opacity-70">Handle</span> | |
| 164 | + <input | |
| 165 | + type="text" | |
| 166 | + name="handle" | |
| 167 | + required | |
| 168 | + placeholder="alice" | |
| 169 | + aria-label="Handle to ban" | |
| 170 | + class="input input-bordered input-sm font-mono" | |
| 171 | + /> | |
| 172 | + </label> | |
| 173 | + <label class="flex flex-col gap-1 flex-1 min-w-48"> | |
| 174 | + <span class="text-xs opacity-70">Reason (optional)</span> | |
| 175 | + <input type="text" name="reason" class="input input-bordered input-sm" /> | |
| 176 | + </label> | |
| 177 | + <label class="flex flex-col gap-1"> | |
| 178 | + <span class="text-xs opacity-70">Until (optional)</span> | |
| 179 | + <input type="date" name="expires_on" class="input input-bordered input-sm" /> | |
| 180 | + </label> | |
| 181 | + <button type="submit" class="btn btn-sm btn-error btn-outline">Ban</button> | |
| 182 | + </form> | |
| 183 | + <p class="text-xs opacity-60 mt-1"> | |
| 184 | + Stops them opening issues and pull requests, commenting and reviewing in this {@scope_label}, and hides what they've already written here. Leave the date | |
| 185 | + empty for a ban that lasts until it's lifted. | |
| 186 | + </p> | |
| 187 | + """ | |
| 188 | + end | |
| 189 | + | |
| 190 | + defp ban_state(ban) do | |
| 191 | + cond do | |
| 192 | + ban.lifted_at -> "lifted" | |
| 193 | + not Ban.active?(ban) -> "expired" | |
| 194 | + ban.expires_at -> "temporary" | |
| 195 | + true -> "banned" | |
| 196 | + end | |
| 197 | + end | |
| 198 | + | |
| 199 | + defp moderatable?(%{moderatable?: true, moderated?: false}), do: true | |
| 200 | + defp moderatable?(_), do: false | |
| 201 | + | |
| 202 | + defp author_id(%{author_id: id}) when is_integer(id), do: id | |
| 203 | + defp author_id(_), do: nil | |
| 204 | + | |
| 205 | + defp reporter_name(%{reporter: %{handle: h}}) when is_binary(h), do: h | |
| 206 | + defp reporter_name(_), do: "someone" | |
| 207 | + | |
| 208 | + defp user_name(%{handle: h}) when is_binary(h), do: h | |
| 209 | + defp user_name(%{email: e}) when is_binary(e), do: e |> String.split("@") |> hd() | |
| 210 | + defp user_name(_), do: "unknown" | |
| 211 | + | |
| 212 | + defp format_dt(nil), do: "—" | |
| 213 | + defp format_dt(dt), do: Calendar.strftime(dt, "%Y-%m-%d") | |
| 214 | +end |
added
lib/git_gud_web/live/moderation_actions.ex
+171
−0
@@ -0,0 +1,171 @@
| 1 | +defmodule GitGudWeb.ModerationActions do | |
| 2 | + @moduledoc """ | |
| 3 | + The moderation actions shared by the org and repo moderation pages. | |
| 4 | + | |
| 5 | + Plain functions over the socket rather than a macro: both pages | |
| 6 | + handle the same five events, and duplicating the bodies is how two | |
| 7 | + scopes quietly grow different rules about who may do what. | |
| 8 | + | |
| 9 | + Every function expects `:scope` (an `%Organization{}` or | |
| 10 | + `%Repository{}`) and `:moderator` in the assigns, plus a reload | |
| 11 | + callback the page provides to re-read its lists. | |
| 12 | + | |
| 13 | + Bans carry their own audit trail — who banned whom, when, why, and | |
| 14 | + who lifted it are all on the row, and lifted rows are kept. There's | |
| 15 | + no separate log to consult. | |
| 16 | + """ | |
| 17 | + | |
| 18 | + import Phoenix.LiveView, only: [put_flash: 3] | |
| 19 | + | |
| 20 | + alias GitGud.Accounts | |
| 21 | + alias GitGud.Moderation | |
| 22 | + alias GitGud.Reports | |
| 23 | + | |
| 24 | + @doc "Dismiss a report as needing nothing." | |
| 25 | + def dismiss_report(socket, id, reload) do | |
| 26 | + with {:ok, report} <- scoped_report(socket, id) do | |
| 27 | + {:ok, _} = Reports.dismiss(report, socket.assigns.moderator) | |
| 28 | + socket |> reload.() |> put_flash(:info, "Report dismissed.") | |
| 29 | + else | |
| 30 | + _ -> put_flash(socket, :error, "That report isn't yours to act on.") | |
| 31 | + end | |
| 32 | + end | |
| 33 | + | |
| 34 | + @doc "Mark a report handled without changing the content." | |
| 35 | + def action_report(socket, id, reload) do | |
| 36 | + with {:ok, report} <- scoped_report(socket, id) do | |
| 37 | + {:ok, _} = Reports.action(report, socket.assigns.moderator) | |
| 38 | + socket |> reload.() |> put_flash(:info, "Report marked handled.") | |
| 39 | + else | |
| 40 | + _ -> put_flash(socket, :error, "That report isn't yours to act on.") | |
| 41 | + end | |
| 42 | + end | |
| 43 | + | |
| 44 | + @doc """ | |
| 45 | + Replace reported content with a moderation message, and close the | |
| 46 | + report. | |
| 47 | + | |
| 48 | + The original is kept and stays visible to admins and its author — | |
| 49 | + this hides it from everyone else rather than destroying it. | |
| 50 | + """ | |
| 51 | + def moderate_report(socket, id, reload) do | |
| 52 | + moderator = socket.assigns.moderator | |
| 53 | + | |
| 54 | + with {:ok, report} <- scoped_report(socket, id), | |
| 55 | + {:ok, _} <- Reports.moderate_target(report, moderator, nil) do | |
| 56 | + {:ok, _} = Reports.action(report, moderator, "content replaced with mod message") | |
| 57 | + socket |> reload.() |> put_flash(:info, "Content replaced.") | |
| 58 | + else | |
| 59 | + _ -> put_flash(socket, :error, "Could not moderate that content.") | |
| 60 | + end | |
| 61 | + end | |
| 62 | + | |
| 63 | + @doc "Ban the author of a reported item, straight from the queue." | |
| 64 | + def ban_reported_author(socket, id, reload) do | |
| 65 | + with {:ok, report} <- scoped_report(socket, id), | |
| 66 | + %{author_id: author_id} when is_integer(author_id) <- Reports.preview(report), | |
| 67 | + %{} = user <- GitGud.Repo.get(GitGud.Accounts.User, author_id) do | |
| 68 | + do_ban(socket, user, nil, nil, reload) | |
| 69 | + else | |
| 70 | + _ -> put_flash(socket, :error, "That report has no local author to ban.") | |
| 71 | + end | |
| 72 | + end | |
| 73 | + | |
| 74 | + @doc "Ban someone by handle." | |
| 75 | + def ban_user(socket, params, reload) do | |
| 76 | + handle = String.trim(params["handle"] || "") | |
| 77 | + | |
| 78 | + case Accounts.get_user_by_handle(handle) do | |
| 79 | + nil -> | |
| 80 | + put_flash(socket, :error, "No such user.") | |
| 81 | + | |
| 82 | + user -> | |
| 83 | + do_ban( | |
| 84 | + socket, | |
| 85 | + user, | |
| 86 | + blank_to_nil(params["reason"]), | |
| 87 | + parse_expiry(params["expires_on"]), | |
| 88 | + reload | |
| 89 | + ) | |
| 90 | + end | |
| 91 | + end | |
| 92 | + | |
| 93 | + defp do_ban(socket, user, reason, expires_at, reload) do | |
| 94 | + scope = socket.assigns.scope | |
| 95 | + moderator = socket.assigns.moderator | |
| 96 | + | |
| 97 | + cond do | |
| 98 | + user.id == moderator.id -> | |
| 99 | + put_flash(socket, :error, "You can't ban yourself.") | |
| 100 | + | |
| 101 | + true -> | |
| 102 | + case Moderation.ban(scope, user, | |
| 103 | + reason: reason, | |
| 104 | + expires_at: expires_at, | |
| 105 | + banned_by: moderator | |
| 106 | + ) do | |
| 107 | + {:ok, _ban} -> | |
| 108 | + socket |> reload.() |> put_flash(:info, "#{user.handle} banned.") | |
| 109 | + | |
| 110 | + {:error, _cs} -> | |
| 111 | + put_flash(socket, :error, "Could not ban #{user.handle} — already banned here?") | |
| 112 | + end | |
| 113 | + end | |
| 114 | + end | |
| 115 | + | |
| 116 | + @doc "Lift a ban." | |
| 117 | + def lift_ban(socket, id, reload) do | |
| 118 | + ban = Moderation.get_ban(id) | |
| 119 | + | |
| 120 | + if ban && in_scope?(ban, socket.assigns.scope) do | |
| 121 | + {:ok, _} = Moderation.lift(ban, socket.assigns.moderator) | |
| 122 | + socket |> reload.() |> put_flash(:info, "Ban lifted.") | |
| 123 | + else | |
| 124 | + put_flash(socket, :error, "That ban isn't yours to lift.") | |
| 125 | + end | |
| 126 | + end | |
| 127 | + | |
| 128 | + # A moderator may only act on reports in their own scope; the id in | |
| 129 | + # the event is not taken on trust. | |
| 130 | + defp scoped_report(socket, id) do | |
| 131 | + report = Reports.get_report!(String.to_integer(id)) | |
| 132 | + | |
| 133 | + if report_in_scope?(report, socket.assigns.scope), | |
| 134 | + do: {:ok, report}, | |
| 135 | + else: :error | |
| 136 | + end | |
| 137 | + | |
| 138 | + defp report_in_scope?(report, %GitGud.Repositories.Repository{id: id}), | |
| 139 | + do: report.repository_id == id | |
| 140 | + | |
| 141 | + defp report_in_scope?(report, %GitGud.Organizations.Organization{id: id}), | |
| 142 | + do: report.organization_id == id | |
| 143 | + | |
| 144 | + defp in_scope?(ban, %GitGud.Repositories.Repository{id: id}), do: ban.repository_id == id | |
| 145 | + defp in_scope?(ban, %GitGud.Organizations.Organization{id: id}), do: ban.organization_id == id | |
| 146 | + | |
| 147 | + defp blank_to_nil(nil), do: nil | |
| 148 | + | |
| 149 | + defp blank_to_nil(s) when is_binary(s) do | |
| 150 | + case String.trim(s) do | |
| 151 | + "" -> nil | |
| 152 | + trimmed -> trimmed | |
| 153 | + end | |
| 154 | + end | |
| 155 | + | |
| 156 | + # The form offers a date; a ban runs to the end of it. | |
| 157 | + defp parse_expiry(nil), do: nil | |
| 158 | + defp parse_expiry(""), do: nil | |
| 159 | + | |
| 160 | + defp parse_expiry(date_string) do | |
| 161 | + case Date.from_iso8601(date_string) do | |
| 162 | + {:ok, date} -> | |
| 163 | + date | |
| 164 | + |> DateTime.new!(~T[23:59:59], "Etc/UTC") | |
| 165 | + |> DateTime.truncate(:second) | |
| 166 | + | |
| 167 | + _ -> | |
| 168 | + nil | |
| 169 | + end | |
| 170 | + end | |
| 171 | +end |
added
lib/git_gud_web/live/org_live/moderation.ex
+110
−0
@@ -0,0 +1,110 @@
| 1 | +defmodule GitGudWeb.OrgLive.Moderation do | |
| 2 | + @moduledoc """ | |
| 3 | + Org admins' moderation page: reports about content in any repo the | |
| 4 | + org owns, and bans that apply across all of them. | |
| 5 | + | |
| 6 | + Before this, a report could only be seen by an instance admin, so an | |
| 7 | + org had no way to learn that its own content had been reported. | |
| 8 | + """ | |
| 9 | + | |
| 10 | + use GitGudWeb, :live_view | |
| 11 | + | |
| 12 | + import GitGudWeb.ModerationAdminComponents | |
| 13 | + | |
| 14 | + alias GitGud.Moderation | |
| 15 | + alias GitGud.Organizations | |
| 16 | + alias GitGud.Organizations.Organization | |
| 17 | + alias GitGud.Reports | |
| 18 | + alias GitGudWeb.ModerationActions | |
| 19 | + | |
| 20 | + @impl true | |
| 21 | + def mount(%{"handle" => handle}, _session, socket) do | |
| 22 | + org = Organizations.get_organization_by_handle!(handle) | |
| 23 | + user = socket.assigns.current_scope.user | |
| 24 | + | |
| 25 | + if Organizations.org_admin?(org, user) do | |
| 26 | + {:ok, | |
| 27 | + socket | |
| 28 | + |> assign(:org, org) | |
| 29 | + |> assign(:scope, org) | |
| 30 | + |> assign(:moderator, user) | |
| 31 | + |> assign(:page_title, "Moderation — @#{org.handle}") | |
| 32 | + |> reload()} | |
| 33 | + else | |
| 34 | + {:ok, | |
| 35 | + socket | |
| 36 | + |> put_flash(:error, "Admins only.") | |
| 37 | + |> push_navigate(to: ~p"/orgs/#{org.handle}")} | |
| 38 | + end | |
| 39 | + end | |
| 40 | + | |
| 41 | + defp reload(socket) do | |
| 42 | + org = socket.assigns.org | |
| 43 | + reports = Reports.list_open_for_org(org) | |
| 44 | + | |
| 45 | + socket | |
| 46 | + |> assign(:reports, reports) | |
| 47 | + |> assign(:previews, Map.new(reports, &{&1.id, Reports.preview(&1)})) | |
| 48 | + |> assign(:bans, Moderation.list_bans(org)) | |
| 49 | + end | |
| 50 | + | |
| 51 | + @impl true | |
| 52 | + def handle_event("dismiss_report", %{"id" => id}, socket), | |
| 53 | + do: {:noreply, ModerationActions.dismiss_report(socket, id, &reload/1)} | |
| 54 | + | |
| 55 | + def handle_event("action_report", %{"id" => id}, socket), | |
| 56 | + do: {:noreply, ModerationActions.action_report(socket, id, &reload/1)} | |
| 57 | + | |
| 58 | + def handle_event("moderate_report", %{"id" => id}, socket), | |
| 59 | + do: {:noreply, ModerationActions.moderate_report(socket, id, &reload/1)} | |
| 60 | + | |
| 61 | + def handle_event("ban_reported_author", %{"id" => id}, socket), | |
| 62 | + do: {:noreply, ModerationActions.ban_reported_author(socket, id, &reload/1)} | |
| 63 | + | |
| 64 | + def handle_event("ban_user", params, socket), | |
| 65 | + do: {:noreply, ModerationActions.ban_user(socket, params, &reload/1)} | |
| 66 | + | |
| 67 | + def handle_event("lift_ban", %{"id" => id}, socket), | |
| 68 | + do: {:noreply, ModerationActions.lift_ban(socket, id, &reload/1)} | |
| 69 | + | |
| 70 | + @impl true | |
| 71 | + def render(assigns) do | |
| 72 | + ~H""" | |
| 73 | + <Layouts.app flash={@flash} current_scope={@current_scope}> | |
| 74 | + <div class="space-y-6 max-w-3xl"> | |
| 75 | + <header> | |
| 76 | + <p class="text-xs opacity-60"> | |
| 77 | + <.link navigate={~p"/orgs/#{@org.handle}"} class="link link-hover"> | |
| 78 | + ← {Organization.display(@org)} | |
| 79 | + </.link> | |
| 80 | + </p> | |
| 81 | + <h1 class="text-xl font-semibold">Moderation</h1> | |
| 82 | + <p class="text-sm opacity-70 mt-1"> | |
| 83 | + Covers every repository {Organization.display(@org)} owns. | |
| 84 | + </p> | |
| 85 | + </header> | |
| 86 | + | |
| 87 | + <section> | |
| 88 | + <h2 class="font-semibold text-sm mb-2"> | |
| 89 | + Open reports | |
| 90 | + <span :if={@reports != []} class="badge badge-sm badge-warning ml-1"> | |
| 91 | + {length(@reports)} | |
| 92 | + </span> | |
| 93 | + </h2> | |
| 94 | + <.report_queue reports={@reports} previews={@previews} /> | |
| 95 | + </section> | |
| 96 | + | |
| 97 | + <section> | |
| 98 | + <h2 class="font-semibold text-sm mb-2">Ban someone</h2> | |
| 99 | + <.ban_form scope_label="organization" /> | |
| 100 | + </section> | |
| 101 | + | |
| 102 | + <section> | |
| 103 | + <h2 class="font-semibold text-sm mb-2">Bans</h2> | |
| 104 | + <.ban_list bans={@bans} scope_label="organization" /> | |
| 105 | + </section> | |
| 106 | + </div> | |
| 107 | + </Layouts.app> | |
| 108 | + """ | |
| 109 | + end | |
| 110 | +end |
modified
lib/git_gud_web/live/org_live/show.ex
+46
−37
@@ -37,7 +37,10 @@ defmodule GitGudWeb.OrgLive.Show do
| 37 | 37 | |> assign(:repos, repos) |
| 38 | 38 | |> assign(:ci_status, Workflows.latest_run_status_by_repo(Enum.map(repos, & &1.id))) |
| 39 | 39 | |> assign(:pins, Profiles.list_pins(org)) |
| 40 | − |> assign(:readme, Repositories.get_profile_readme(org, theme: socket.assigns[:editor_theme])) | |
| 40 | + |> assign( | |
| 41 | + :readme, | |
| 42 | + Repositories.get_profile_readme(org, theme: socket.assigns[:editor_theme]) | |
| 43 | + ) | |
| 41 | 44 | |> assign_team_form(Team.changeset(%Team{}, %{})) |
| 42 | 45 | |> assign(:page_title, "@#{org.handle}")} |
| 43 | 46 | end |
@@ -113,42 +116,48 @@ defmodule GitGudWeb.OrgLive.Show do
| 113 | 116 | <p class="text-sm font-mono opacity-60">@{@org.handle}</p> |
| 114 | 117 | <p :if={@org.description} class="text-sm opacity-70 mt-1">{@org.description}</p> |
| 115 | 118 | <nav :if={@is_admin?} class="text-xs flex gap-3 opacity-80 mt-2"> |
| 116 | − <.link | |
| 117 | − navigate={~p"/orgs/#{@org.handle}/settings/profile"} | |
| 118 | − class="link link-hover" | |
| 119 | − > | |
| 120 | − Profile | |
| 121 | − </.link> | |
| 122 | − <.link | |
| 123 | − navigate={~p"/orgs/#{@org.handle}/settings/members"} | |
| 124 | − class="link link-hover" | |
| 125 | − > | |
| 126 | − Members | |
| 127 | − </.link> | |
| 128 | − <.link | |
| 129 | − navigate={~p"/orgs/#{@org.handle}/settings/secrets"} | |
| 130 | − class="link link-hover" | |
| 131 | − > | |
| 132 | − Secrets & variables | |
| 133 | − </.link> | |
| 134 | − <.link | |
| 135 | − navigate={~p"/orgs/#{@org.handle}/settings/runners"} | |
| 136 | − class="link link-hover" | |
| 137 | − > | |
| 138 | − Runners | |
| 139 | − </.link> | |
| 140 | − <.link | |
| 141 | − navigate={~p"/orgs/#{@org.handle}/settings/webhooks"} | |
| 142 | − class="link link-hover" | |
| 143 | − > | |
| 144 | − Webhooks | |
| 145 | − </.link> | |
| 146 | − <.link | |
| 147 | − navigate={~p"/orgs/#{@org.handle}/settings/labels"} | |
| 148 | − class="link link-hover" | |
| 149 | − > | |
| 150 | − Labels | |
| 151 | − </.link> | |
| 119 | + <.link | |
| 120 | + navigate={~p"/orgs/#{@org.handle}/settings/profile"} | |
| 121 | + class="link link-hover" | |
| 122 | + > | |
| 123 | + Profile | |
| 124 | + </.link> | |
| 125 | + <.link | |
| 126 | + navigate={~p"/orgs/#{@org.handle}/settings/members"} | |
| 127 | + class="link link-hover" | |
| 128 | + > | |
| 129 | + Members | |
| 130 | + </.link> | |
| 131 | + <.link | |
| 132 | + navigate={~p"/orgs/#{@org.handle}/settings/secrets"} | |
| 133 | + class="link link-hover" | |
| 134 | + > | |
| 135 | + Secrets & variables | |
| 136 | + </.link> | |
| 137 | + <.link | |
| 138 | + navigate={~p"/orgs/#{@org.handle}/settings/runners"} | |
| 139 | + class="link link-hover" | |
| 140 | + > | |
| 141 | + Runners | |
| 142 | + </.link> | |
| 143 | + <.link | |
| 144 | + navigate={~p"/orgs/#{@org.handle}/settings/webhooks"} | |
| 145 | + class="link link-hover" | |
| 146 | + > | |
| 147 | + Webhooks | |
| 148 | + </.link> | |
| 149 | + <.link | |
| 150 | + navigate={~p"/orgs/#{@org.handle}/settings/labels"} | |
| 151 | + class="link link-hover" | |
| 152 | + > | |
| 153 | + Labels | |
| 154 | + </.link> | |
| 155 | + <.link | |
| 156 | + navigate={~p"/orgs/#{@org.handle}/settings/moderation"} | |
| 157 | + class="link link-hover" | |
| 158 | + > | |
| 159 | + Moderation | |
| 160 | + </.link> | |
| 152 | 161 | </nav> |
| 153 | 162 | <nav class="text-xs flex gap-3 opacity-80 mt-2"> |
| 154 | 163 | <.link navigate={~p"/orgs/#{@org.handle}/actions"} class="link link-hover"> |
added
lib/git_gud_web/live/repo_live/moderation.ex
+136
−0
@@ -0,0 +1,136 @@
| 1 | +defmodule GitGudWeb.RepoLive.Moderation do | |
| 2 | + @moduledoc """ | |
| 3 | + Repo admins' moderation page: reports about content in this repo, and | |
| 4 | + bans that apply to it. | |
| 5 | + | |
| 6 | + An org-wide ban set by the owning org also stops someone here, but is | |
| 7 | + listed and lifted on the org's own page — a repo admin shouldn't be | |
| 8 | + able to undo a decision made above them. | |
| 9 | + """ | |
| 10 | + | |
| 11 | + use GitGudWeb, :live_view | |
| 12 | + | |
| 13 | + import GitGudWeb.ModerationAdminComponents | |
| 14 | + | |
| 15 | + alias GitGud.Moderation | |
| 16 | + alias GitGud.Reports | |
| 17 | + alias GitGud.Repositories | |
| 18 | + alias GitGud.Repositories.Storage | |
| 19 | + alias GitGudWeb.ModerationActions | |
| 20 | + | |
| 21 | + @impl true | |
| 22 | + def mount(%{"owner" => owner, "name" => name}, _session, socket) do | |
| 23 | + repo = | |
| 24 | + Repositories.get_repository_by_path!(owner, name) | |
| 25 | + |> GitGud.Repo.preload([:owner, :organization]) | |
| 26 | + | |
| 27 | + user = socket.assigns.current_scope.user | |
| 28 | + | |
| 29 | + if admin?(user, repo) do | |
| 30 | + {:ok, | |
| 31 | + socket | |
| 32 | + |> assign(:repo, repo) | |
| 33 | + |> assign(:handle, Storage.repo_handle(repo)) | |
| 34 | + |> assign(:scope, repo) | |
| 35 | + |> assign(:moderator, user) | |
| 36 | + |> GitGudWeb.RepoLive.Header.assign_chrome(repo) | |
| 37 | + |> assign(:page_title, "Moderation — #{repo.name}") | |
| 38 | + |> reload()} | |
| 39 | + else | |
| 40 | + {:ok, | |
| 41 | + socket | |
| 42 | + |> put_flash(:error, "Admins only.") | |
| 43 | + |> push_navigate(to: ~p"/r/#{Storage.repo_handle(repo)}/#{repo.name}")} | |
| 44 | + end | |
| 45 | + end | |
| 46 | + | |
| 47 | + defp admin?(user, %{owner_id: uid, organization_id: nil}), do: user.id == uid | |
| 48 | + | |
| 49 | + defp admin?(user, %{organization: %{} = org}), | |
| 50 | + do: GitGud.Organizations.org_admin?(org, user) | |
| 51 | + | |
| 52 | + defp admin?(_user, _repo), do: false | |
| 53 | + | |
| 54 | + defp reload(socket) do | |
| 55 | + repo = socket.assigns.repo | |
| 56 | + reports = Reports.list_open_for_repo(repo) | |
| 57 | + | |
| 58 | + socket | |
| 59 | + |> assign(:reports, reports) | |
| 60 | + |> assign(:previews, Map.new(reports, &{&1.id, Reports.preview(&1)})) | |
| 61 | + |> assign(:bans, Moderation.list_bans(repo)) | |
| 62 | + end | |
| 63 | + | |
| 64 | + @impl true | |
| 65 | + def handle_event("dismiss_report", %{"id" => id}, socket), | |
| 66 | + do: {:noreply, ModerationActions.dismiss_report(socket, id, &reload/1)} | |
| 67 | + | |
| 68 | + def handle_event("action_report", %{"id" => id}, socket), | |
| 69 | + do: {:noreply, ModerationActions.action_report(socket, id, &reload/1)} | |
| 70 | + | |
| 71 | + def handle_event("moderate_report", %{"id" => id}, socket), | |
| 72 | + do: {:noreply, ModerationActions.moderate_report(socket, id, &reload/1)} | |
| 73 | + | |
| 74 | + def handle_event("ban_reported_author", %{"id" => id}, socket), | |
| 75 | + do: {:noreply, ModerationActions.ban_reported_author(socket, id, &reload/1)} | |
| 76 | + | |
| 77 | + def handle_event("ban_user", params, socket), | |
| 78 | + do: {:noreply, ModerationActions.ban_user(socket, params, &reload/1)} | |
| 79 | + | |
| 80 | + def handle_event("lift_ban", %{"id" => id}, socket), | |
| 81 | + do: {:noreply, ModerationActions.lift_ban(socket, id, &reload/1)} | |
| 82 | + | |
| 83 | + @impl true | |
| 84 | + def render(assigns) do | |
| 85 | + ~H""" | |
| 86 | + <Layouts.app flash={@flash} current_scope={@current_scope}> | |
| 87 | + <div class="space-y-6 max-w-3xl"> | |
| 88 | + <GitGudWeb.RepoLive.Header.header | |
| 89 | + repo={@repo} | |
| 90 | + handle={@handle} | |
| 91 | + current_scope={@current_scope} | |
| 92 | + has_packages?={@has_packages?} | |
| 93 | + can_admin?={@can_admin?} | |
| 94 | + latest_run={@latest_run} | |
| 95 | + open_pulls={@open_pulls} | |
| 96 | + /> | |
| 97 | + | |
| 98 | + <header> | |
| 99 | + <h1 class="text-xl font-semibold">Moderation</h1> | |
| 100 | + <p :if={@repo.organization} class="text-sm opacity-70 mt-1"> | |
| 101 | + {@repo.organization.handle} may also ban people across all its | |
| 102 | + repositories — | |
| 103 | + <.link | |
| 104 | + navigate={~p"/orgs/#{@repo.organization.handle}/settings/moderation"} | |
| 105 | + class="link link-hover" | |
| 106 | + > | |
| 107 | + its moderation page | |
| 108 | + </.link> | |
| 109 | + lists those. | |
| 110 | + </p> | |
| 111 | + </header> | |
| 112 | + | |
| 113 | + <section> | |
| 114 | + <h2 class="font-semibold text-sm mb-2"> | |
| 115 | + Open reports | |
| 116 | + <span :if={@reports != []} class="badge badge-sm badge-warning ml-1"> | |
| 117 | + {length(@reports)} | |
| 118 | + </span> | |
| 119 | + </h2> | |
| 120 | + <.report_queue reports={@reports} previews={@previews} /> | |
| 121 | + </section> | |
| 122 | + | |
| 123 | + <section> | |
| 124 | + <h2 class="font-semibold text-sm mb-2">Ban someone</h2> | |
| 125 | + <.ban_form scope_label="repository" /> | |
| 126 | + </section> | |
| 127 | + | |
| 128 | + <section> | |
| 129 | + <h2 class="font-semibold text-sm mb-2">Bans</h2> | |
| 130 | + <.ban_list bans={@bans} scope_label="repository" /> | |
| 131 | + </section> | |
| 132 | + </div> | |
| 133 | + </Layouts.app> | |
| 134 | + """ | |
| 135 | + end | |
| 136 | +end |
modified
lib/git_gud_web/router.ex
+2
−0
@@ -170,6 +170,7 @@ defmodule GitGudWeb.Router do
| 170 | 170 | live "/r/:owner/:name/settings/secrets", SecretsLive.Repo, :index |
| 171 | 171 | live "/r/:owner/:name/settings/registry-tokens", RegistryTokenLive.Index, :index |
| 172 | 172 | live "/r/:owner/:name/settings/runners", RepoLive.SettingsRunners, :index |
| 173 | + live "/r/:owner/:name/settings/moderation", RepoLive.Moderation, :index | |
| 173 | 174 | live "/r/:owner/:name/settings", RepoLive.Settings, :edit |
| 174 | 175 | live "/orgs/:handle/settings/profile", OrgLive.SettingsProfile, :index |
| 175 | 176 | live "/orgs/:handle/settings/members", OrgLive.Members, :index |
@@ -177,6 +178,7 @@ defmodule GitGudWeb.Router do
| 177 | 178 | live "/orgs/:handle/settings/runners", OrgLive.SettingsRunners, :index |
| 178 | 179 | live "/orgs/:handle/settings/webhooks", WebhookLive.Org, :index |
| 179 | 180 | live "/orgs/:handle/settings/labels", OrgLive.Labels, :index |
| 181 | + live "/orgs/:handle/settings/moderation", OrgLive.Moderation, :index | |
| 180 | 182 | live "/users/ci", UserLive.Ci, :index |
| 181 | 183 | live "/r/:owner/:name/fork", RepoLive.Fork, :new |
| 182 | 184 | live "/orgs", OrgLive.Index, :index |
added
test/git_gud_web/live/moderation_page_test.exs
+258
−0
@@ -0,0 +1,258 @@
| 1 | +defmodule GitGudWeb.ModerationPageTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The org and repo moderation pages: who can reach them, what they | |
| 4 | + show, and that a moderator can only act within their own scope. | |
| 5 | + """ | |
| 6 | + | |
| 7 | + use GitGudWeb.ConnCase, async: false | |
| 8 | + | |
| 9 | + import Phoenix.LiveViewTest | |
| 10 | + import GitGud.AccountsFixtures | |
| 11 | + import GitGud.ForgeFixtures | |
| 12 | + | |
| 13 | + alias GitGud.Issues | |
| 14 | + alias GitGud.Moderation | |
| 15 | + alias GitGud.Organizations | |
| 16 | + alias GitGud.Reports | |
| 17 | + alias GitGud.Repositories | |
| 18 | + | |
| 19 | + defp repo_mod_path(repo) do | |
| 20 | + handle = Repositories.Storage.repo_handle(GitGud.Repo.preload(repo, [:owner, :organization])) | |
| 21 | + ~p"/r/#{handle}/#{repo.name}/settings/moderation" | |
| 22 | + end | |
| 23 | + | |
| 24 | + defp org_setup(handle) do | |
| 25 | + admin = user_fixture() | |
| 26 | + {:ok, org} = Organizations.create_organization(admin, %{"handle" => handle}) | |
| 27 | + | |
| 28 | + {:ok, repo} = | |
| 29 | + Repositories.create_repository_for_org(org, admin, %{ | |
| 30 | + "name" => "modrepo", | |
| 31 | + "visibility" => "public" | |
| 32 | + }) | |
| 33 | + | |
| 34 | + {admin, org, repo} | |
| 35 | + end | |
| 36 | + | |
| 37 | + describe "access" do | |
| 38 | + test "a repo owner reaches their moderation page", %{conn: conn} do | |
| 39 | + {owner, repo} = repository_fixture() | |
| 40 | + | |
| 41 | + {:ok, _lv, html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 42 | + assert html =~ "Moderation" | |
| 43 | + end | |
| 44 | + | |
| 45 | + test "an unrelated user is bounced", %{conn: conn} do | |
| 46 | + {_owner, repo} = repository_fixture(%{visibility: "public"}) | |
| 47 | + outsider = user_fixture() | |
| 48 | + | |
| 49 | + assert {:error, {:live_redirect, _}} = | |
| 50 | + live(log_in_user(conn, outsider), repo_mod_path(repo)) | |
| 51 | + end | |
| 52 | + | |
| 53 | + test "an org admin reaches the org page", %{conn: conn} do | |
| 54 | + {admin, org, _repo} = org_setup("mod-access") | |
| 55 | + | |
| 56 | + {:ok, _lv, html} = | |
| 57 | + live(log_in_user(conn, admin), ~p"/orgs/#{org.handle}/settings/moderation") | |
| 58 | + | |
| 59 | + assert html =~ "Moderation" | |
| 60 | + assert html =~ "every repository" | |
| 61 | + end | |
| 62 | + | |
| 63 | + test "a plain org member is bounced from the org page", %{conn: conn} do | |
| 64 | + {_admin, org, _repo} = org_setup("mod-member") | |
| 65 | + member = user_fixture() | |
| 66 | + {:ok, _} = Organizations.add_member(org, member, "member") | |
| 67 | + | |
| 68 | + assert {:error, {:live_redirect, _}} = | |
| 69 | + live(log_in_user(conn, member), ~p"/orgs/#{org.handle}/settings/moderation") | |
| 70 | + end | |
| 71 | + end | |
| 72 | + | |
| 73 | + describe "the report queue" do | |
| 74 | + test "shows a report about this repo's content", %{conn: conn} do | |
| 75 | + {owner, repo} = repository_fixture(%{visibility: "public"}) | |
| 76 | + author = user_fixture() | |
| 77 | + issue = issue_fixture(repo, author, %{"title" => "Reported thing"}) | |
| 78 | + | |
| 79 | + reporter = user_fixture() | |
| 80 | + {:ok, _} = Reports.open(reporter, {"issue", issue.id}, "spam") | |
| 81 | + | |
| 82 | + {:ok, _lv, html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 83 | + | |
| 84 | + assert html =~ "spam" | |
| 85 | + assert html =~ "Reported thing" | |
| 86 | + assert html =~ reporter.handle | |
| 87 | + end | |
| 88 | + | |
| 89 | + test "a report about another repo doesn't appear", %{conn: conn} do | |
| 90 | + {owner, repo} = repository_fixture(%{visibility: "public"}) | |
| 91 | + {other_owner, other} = repository_fixture(%{visibility: "public"}) | |
| 92 | + issue = issue_fixture(other, other_owner, %{"title" => "Elsewhere"}) | |
| 93 | + | |
| 94 | + {:ok, _} = Reports.open(user_fixture(), {"issue", issue.id}, "spam") | |
| 95 | + | |
| 96 | + {:ok, _lv, html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 97 | + | |
| 98 | + refute html =~ "Elsewhere" | |
| 99 | + assert html =~ "Nothing reported here." | |
| 100 | + end | |
| 101 | + | |
| 102 | + test "the org page sees reports from its repos", %{conn: conn} do | |
| 103 | + {admin, org, repo} = org_setup("mod-org-queue") | |
| 104 | + issue = issue_fixture(repo, user_fixture(), %{"title" => "Org-owned issue"}) | |
| 105 | + {:ok, _} = Reports.open(user_fixture(), {"issue", issue.id}, "abuse") | |
| 106 | + | |
| 107 | + {:ok, _lv, html} = | |
| 108 | + live(log_in_user(conn, admin), ~p"/orgs/#{org.handle}/settings/moderation") | |
| 109 | + | |
| 110 | + assert html =~ "Org-owned issue" | |
| 111 | + end | |
| 112 | + | |
| 113 | + test "dismissing closes it", %{conn: conn} do | |
| 114 | + {owner, repo} = repository_fixture(%{visibility: "public"}) | |
| 115 | + issue = issue_fixture(repo, user_fixture()) | |
| 116 | + {:ok, report} = Reports.open(user_fixture(), {"issue", issue.id}, "spam") | |
| 117 | + | |
| 118 | + {:ok, lv, _html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 119 | + html = render_hook(lv, "dismiss_report", %{"id" => to_string(report.id)}) | |
| 120 | + | |
| 121 | + assert html =~ "Nothing reported here." | |
| 122 | + assert Reports.get_report!(report.id).state == "dismissed" | |
| 123 | + end | |
| 124 | + | |
| 125 | + test "replacing content keeps the original for admins", %{conn: conn} do | |
| 126 | + {owner, repo} = repository_fixture(%{visibility: "public"}) | |
| 127 | + issue = issue_fixture(repo, user_fixture(), %{"body" => "the offending words"}) | |
| 128 | + {:ok, report} = Reports.open(user_fixture(), {"issue", issue.id}, "abuse") | |
| 129 | + | |
| 130 | + {:ok, lv, _html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 131 | + render_hook(lv, "moderate_report", %{"id" => to_string(report.id)}) | |
| 132 | + | |
| 133 | + reloaded = Issues.get_issue!(repo, issue.number) | |
| 134 | + assert reloaded.moderated_at | |
| 135 | + assert reloaded.original_body == "the offending words" | |
| 136 | + assert Reports.get_report!(report.id).state == "actioned" | |
| 137 | + end | |
| 138 | + | |
| 139 | + test "a moderator can't act on a report outside their scope", %{conn: conn} do | |
| 140 | + {owner, repo} = repository_fixture(%{visibility: "public"}) | |
| 141 | + {other_owner, other} = repository_fixture(%{visibility: "public"}) | |
| 142 | + issue = issue_fixture(other, other_owner) | |
| 143 | + {:ok, foreign} = Reports.open(user_fixture(), {"issue", issue.id}, "spam") | |
| 144 | + | |
| 145 | + {:ok, lv, _html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 146 | + html = render_hook(lv, "dismiss_report", %{"id" => to_string(foreign.id)}) | |
| 147 | + | |
| 148 | + assert html =~ "isn't yours to act on" | |
| 149 | + assert Reports.get_report!(foreign.id).state == "open" | |
| 150 | + end | |
| 151 | + end | |
| 152 | + | |
| 153 | + describe "bans" do | |
| 154 | + test "banning by handle stops them writing", %{conn: conn} do | |
| 155 | + {owner, repo} = repository_fixture(%{visibility: "public"}) | |
| 156 | + nuisance = user_fixture() | |
| 157 | + | |
| 158 | + {:ok, lv, _html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 159 | + | |
| 160 | + html = | |
| 161 | + lv | |
| 162 | + |> form("form[phx-submit=ban_user]", %{handle: nuisance.handle, reason: "spam"}) | |
| 163 | + |> render_submit() | |
| 164 | + | |
| 165 | + assert html =~ nuisance.handle | |
| 166 | + assert Moderation.banned?(repo, nuisance) | |
| 167 | + assert {:error, :banned} = Issues.create_issue(repo, nuisance, %{"title" => "nope"}) | |
| 168 | + end | |
| 169 | + | |
| 170 | + test "an unknown handle is reported", %{conn: conn} do | |
| 171 | + {owner, repo} = repository_fixture() | |
| 172 | + | |
| 173 | + {:ok, lv, _html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 174 | + html = lv |> form("form[phx-submit=ban_user]", %{handle: "nobody"}) |> render_submit() | |
| 175 | + | |
| 176 | + assert html =~ "No such user." | |
| 177 | + end | |
| 178 | + | |
| 179 | + test "a moderator can't ban themselves", %{conn: conn} do | |
| 180 | + {owner, repo} = repository_fixture() | |
| 181 | + | |
| 182 | + {:ok, lv, _html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 183 | + html = lv |> form("form[phx-submit=ban_user]", %{handle: owner.handle}) |> render_submit() | |
| 184 | + | |
| 185 | + assert html =~ "can't ban yourself" | |
| 186 | + refute Moderation.banned?(repo, owner) | |
| 187 | + end | |
| 188 | + | |
| 189 | + test "lifting restores them", %{conn: conn} do | |
| 190 | + {owner, repo} = repository_fixture(%{visibility: "public"}) | |
| 191 | + nuisance = user_fixture() | |
| 192 | + {:ok, ban} = Moderation.ban(repo, nuisance, banned_by: owner) | |
| 193 | + | |
| 194 | + {:ok, lv, _html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 195 | + render_hook(lv, "lift_ban", %{"id" => to_string(ban.id)}) | |
| 196 | + | |
| 197 | + refute Moderation.banned?(repo, nuisance) | |
| 198 | + assert {:ok, _} = Issues.create_issue(repo, nuisance, %{"title" => "back"}) | |
| 199 | + end | |
| 200 | + | |
| 201 | + test "a repo admin can't lift the owning org's ban", %{conn: conn} do | |
| 202 | + {admin, org, repo} = org_setup("mod-no-undercut") | |
| 203 | + nuisance = user_fixture() | |
| 204 | + {:ok, org_ban} = Moderation.ban(org, nuisance, banned_by: admin) | |
| 205 | + | |
| 206 | + {:ok, lv, _html} = live(log_in_user(conn, admin), repo_mod_path(repo)) | |
| 207 | + html = render_hook(lv, "lift_ban", %{"id" => to_string(org_ban.id)}) | |
| 208 | + | |
| 209 | + assert html =~ "isn't yours to lift" | |
| 210 | + assert Moderation.banned?(repo, nuisance) | |
| 211 | + end | |
| 212 | + | |
| 213 | + test "banning the reported author works from the queue", %{conn: conn} do | |
| 214 | + {owner, repo} = repository_fixture(%{visibility: "public"}) | |
| 215 | + author = user_fixture() | |
| 216 | + issue = issue_fixture(repo, author) | |
| 217 | + {:ok, report} = Reports.open(user_fixture(), {"issue", issue.id}, "abuse") | |
| 218 | + | |
| 219 | + {:ok, lv, _html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 220 | + render_hook(lv, "ban_reported_author", %{"id" => to_string(report.id)}) | |
| 221 | + | |
| 222 | + assert Moderation.banned?(repo, author) | |
| 223 | + end | |
| 224 | + | |
| 225 | + test "a dated ban is recorded with its expiry", %{conn: conn} do | |
| 226 | + {owner, repo} = repository_fixture(%{visibility: "public"}) | |
| 227 | + nuisance = user_fixture() | |
| 228 | + tomorrow = Date.utc_today() |> Date.add(1) |> Date.to_iso8601() | |
| 229 | + | |
| 230 | + {:ok, lv, _html} = live(log_in_user(conn, owner), repo_mod_path(repo)) | |
| 231 | + | |
| 232 | + _ = | |
| 233 | + lv | |
| 234 | + |> form("form[phx-submit=ban_user]", %{handle: nuisance.handle, expires_on: tomorrow}) | |
| 235 | + |> render_submit() | |
| 236 | + | |
| 237 | + assert [ban] = Moderation.list_bans(repo) | |
| 238 | + assert ban.expires_at | |
| 239 | + assert Moderation.banned?(repo, nuisance) | |
| 240 | + end | |
| 241 | + | |
| 242 | + test "an org ban shows on the org page, not the repo's list", %{conn: conn} do | |
| 243 | + {admin, org, repo} = org_setup("mod-listing") | |
| 244 | + nuisance = user_fixture() | |
| 245 | + {:ok, _} = Moderation.ban(org, nuisance, banned_by: admin) | |
| 246 | + | |
| 247 | + {:ok, _lv, org_html} = | |
| 248 | + live(log_in_user(conn, admin), ~p"/orgs/#{org.handle}/settings/moderation") | |
| 249 | + | |
| 250 | + {:ok, _lv, repo_html} = live(log_in_user(conn, admin), repo_mod_path(repo)) | |
| 251 | + | |
| 252 | + assert org_html =~ nuisance.handle | |
| 253 | + assert repo_html =~ "Nobody is banned from this repository" | |
| 254 | + # But the repo page says where to look. | |
| 255 | + assert repo_html =~ "its moderation page" | |
| 256 | + end | |
| 257 | + end | |
| 258 | +end |
Parents: 7accb73