Give orgs and repos their own moderation pages

500bdf9 · Gabriel Morell · 2026-09-10 16:13

9 files +959 -44
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 &lt;user&gt;</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 &amp; 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 &amp; 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&#39;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&#39;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&#39;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