Pages ask again before they change anything; reports name only what's on the page

e3f46f9 · Gabriel Morell · 2026-10-18 19:30

32 files +683 -131
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/reports.ex
+4 −0
@@ -36,6 +36,10 @@ defmodule GitGud.Reports do
36 36
37 37 def get_report!(id), do: Repo.get!(Report, id)
38 38
39 + @doc "The report with this id, or `nil` (also for a `nil` id)."
40 + def get_report(nil), do: nil
41 + def get_report(id) when is_integer(id), do: Repo.get(Report, id)
42 +
39 43 def list_for_target({type, id}) do
40 44 Report
41 45 |> where([r], r.target_type == ^type and r.target_id == ^id)
modified lib/git_gud_web/live/issue_live/show.ex
+50 −21
@@ -71,7 +71,46 @@ defmodule GitGudWeb.IssueLive.Show do
71 71 defp can_edit_comment?(_assigns, _comment), do: false
72 72
73 73 defp find_comment(socket, id) do
74 − Enum.find(socket.assigns.issue.comments, &(&1.id == String.to_integer(id)))
74 + id = GitGudWeb.RepoAdmin.parse_id(id)
75 + Enum.find(socket.assigns.issue.comments, &(&1.id == id))
76 + end
77 +
78 + # What a report from this page may name: this issue and its comments.
79 + # Anything else — another repository's content, a private issue — is
80 + # refused the same way whether it exists or not.
81 + defp reportable(socket, "issue", id) do
82 + issue = socket.assigns.issue
83 + if GitGudWeb.RepoAdmin.parse_id(id) == issue.id, do: {"issue", issue.id}
84 + end
85 +
86 + defp reportable(socket, "issue_comment", id) do
87 + case find_comment(socket, id) do
88 + nil -> nil
89 + comment -> {"issue_comment", comment.id}
90 + end
91 + end
92 +
93 + defp reportable(_socket, _type, _id), do: nil
94 +
95 + defp add_comment(socket, user, attrs) do
96 + case Issues.add_comment(socket.assigns.issue, user, attrs) do
97 + {:error, :banned} ->
98 + {:noreply, put_flash(socket, :error, "You're banned from this repository.")}
99 +
100 + {:ok, comment} ->
101 + :ok =
102 + Events.record(socket.assigns.issue, "comment_added", user, %{comment_id: comment.id})
103 +
104 + {:noreply,
105 + socket
106 + |> assign_comment_form()
107 + |> assign(:comment_body, "")
108 + |> assign(:event_count, Events.count_for(socket.assigns.issue))
109 + |> reload()}
110 +
111 + {:error, cs} ->
112 + {:noreply, assign(socket, :comment_form, to_form(cs))}
113 + end
75 114 end
76 115
77 116 @impl true
@@ -248,7 +287,7 @@ defmodule GitGudWeb.IssueLive.Show do
248 287 end
249 288
250 289 def handle_event("quote_reply", %{"id" => id}, socket) do
251 − case Enum.find(socket.assigns.issue.comments, &(&1.id == String.to_integer(id))) do
290 + case find_comment(socket, id) do
252 291 nil ->
253 292 {:noreply, socket}
254 293
@@ -261,22 +300,9 @@ defmodule GitGudWeb.IssueLive.Show do
261 300 end
262 301
263 302 def handle_event("add_comment", %{"issue_comment" => attrs}, socket) do
264 − user = socket.assigns.current_scope.user
265 −
266 − case Issues.add_comment(socket.assigns.issue, user, attrs) do
267 − {:ok, comment} ->
268 − :ok =
269 − Events.record(socket.assigns.issue, "comment_added", user, %{comment_id: comment.id})
270 −
271 − {:noreply,
272 − socket
273 − |> assign_comment_form()
274 − |> assign(:comment_body, "")
275 − |> assign(:event_count, Events.count_for(socket.assigns.issue))
276 − |> reload()}
277 −
278 − {:error, cs} ->
279 − {:noreply, assign(socket, :comment_form, to_form(cs))}
303 + case viewer(socket) do
304 + nil -> {:noreply, put_flash(socket, :error, "Sign in to comment.")}
305 + user -> add_comment(socket, user, attrs)
280 306 end
281 307 end
282 308
@@ -290,9 +316,12 @@ defmodule GitGudWeb.IssueLive.Show do
290 316 {:noreply, put_flash(socket, :error, "Sign in to report.")}
291 317
292 318 user ->
293 − case GitGud.Reports.open(user, {type, String.to_integer(id)}, reason) do
294 − {:ok, _} ->
295 − {:noreply, put_flash(socket, :info, "Report submitted. Thanks.")}
319 + with target when not is_nil(target) <- reportable(socket, type, id),
320 + {:ok, _} <- GitGud.Reports.open(user, target, reason) do
321 + {:noreply, put_flash(socket, :info, "Report submitted. Thanks.")}
322 + else
323 + nil ->
324 + {:noreply, put_flash(socket, :error, "That can't be reported from here.")}
296 325
297 326 {:error, :already_moderated} ->
298 327 {:noreply, put_flash(socket, :info, "This content has already been moderated.")}
modified lib/git_gud_web/live/job_token_live/index.ex
+6 −3
@@ -37,7 +37,8 @@ defmodule GitGudWeb.JobTokenLive.Index do
37 37 }
38 38
39 39 cond do
40 − not socket.assigns.can_admin? ->
40 + # Asked again, not read from mount: the socket can outlive the role.
41 + not GitGudWeb.RepoAdmin.admin_now?(socket) ->
41 42 {:noreply, put_flash(socket, :error, "Not allowed.")}
42 43
43 44 source == "" ->
@@ -60,8 +61,10 @@ defmodule GitGudWeb.JobTokenLive.Index do
60 61 end
61 62
62 63 def handle_event("delete_grant", %{"id" => id}, socket) do
63 − if socket.assigns.can_admin? do
64 − _ = JobTokenGrants.delete(socket.assigns.repo, String.to_integer(id))
64 + if GitGudWeb.RepoAdmin.admin_now?(socket) do
65 + if id = GitGudWeb.RepoAdmin.parse_id(id),
66 + do: _ = JobTokenGrants.delete(socket.assigns.repo, id)
67 +
65 68 {:noreply, socket |> load() |> put_flash(:info, "Grant removed.")}
66 69 else
67 70 {:noreply, put_flash(socket, :error, "Not allowed.")}
modified lib/git_gud_web/live/moderation_actions.ex
+70 −24
@@ -19,10 +19,67 @@ defmodule GitGudWeb.ModerationActions do
19 19
20 20 alias GitGud.Accounts
21 21 alias GitGud.Moderation
22 + alias GitGud.Organizations
23 + alias GitGud.Organizations.Organization
22 24 alias GitGud.Reports
25 + alias GitGud.Repositories.Repository
23 26
24 27 @doc "Dismiss a report as needing nothing."
25 − def dismiss_report(socket, id, reload) do
28 + def dismiss_report(socket, id, reload),
29 + do: as_moderator(socket, fn -> do_dismiss_report(socket, id, reload) end)
30 +
31 + @doc "Mark a report handled without changing the content."
32 + def action_report(socket, id, reload),
33 + do: as_moderator(socket, fn -> do_action_report(socket, id, reload) end)
34 +
35 + @doc """
36 + Replace reported content with a moderation message, and close the
37 + report.
38 +
39 + The original is kept and stays visible to admins and its author —
40 + this hides it from everyone else rather than destroying it.
41 + """
42 + def moderate_report(socket, id, reload),
43 + do: as_moderator(socket, fn -> do_moderate_report(socket, id, reload) end)
44 +
45 + @doc "Ban the author of a reported item, straight from the queue."
46 + def ban_reported_author(socket, id, reload),
47 + do: as_moderator(socket, fn -> do_ban_reported_author(socket, id, reload) end)
48 +
49 + @doc "Ban someone by handle."
50 + def ban_user(socket, params, reload),
51 + do: as_moderator(socket, fn -> do_ban_user(socket, params, reload) end)
52 +
53 + @doc "Lift a ban."
54 + def lift_ban(socket, id, reload),
55 + do: as_moderator(socket, fn -> do_lift_ban(socket, id, reload) end)
56 +
57 + # Each action asks again whether the socket's user still moderates
58 + # this scope, rather than trusting the mount: a page left open can
59 + # outlive the admin role it was opened with.
60 + defp as_moderator(socket, fun) do
61 + if still_moderator?(socket),
62 + do: fun.(),
63 + else: put_flash(socket, :error, "You don't moderate here any more.")
64 + end
65 +
66 + defp still_moderator?(%{assigns: %{scope: %Repository{id: id}, moderator: moderator}}) do
67 + case GitGud.Repo.get(Repository, id) do
68 + nil -> false
69 + repo -> GitGudWeb.RepoAdmin.admin?(moderator, GitGud.Repo.preload(repo, :organization))
70 + end
71 + end
72 +
73 + defp still_moderator?(%{assigns: %{scope: %Organization{id: id}, moderator: moderator}}) do
74 + case GitGud.Repo.get(Organization, id) do
75 + nil -> false
76 + org -> Organizations.org_admin?(org, moderator)
77 + end
78 + end
79 +
80 + defp still_moderator?(_socket), do: false
81 +
82 + defp do_dismiss_report(socket, id, reload) do
26 83 with {:ok, report} <- scoped_report(socket, id) do
27 84 {:ok, _} = Reports.dismiss(report, socket.assigns.moderator)
28 85 socket |> reload.() |> put_flash(:info, "Report dismissed.")
@@ -31,8 +88,7 @@ defmodule GitGudWeb.ModerationActions do
31 88 end
32 89 end
33 90
34 − @doc "Mark a report handled without changing the content."
35 − def action_report(socket, id, reload) do
91 + defp do_action_report(socket, id, reload) do
36 92 with {:ok, report} <- scoped_report(socket, id) do
37 93 {:ok, _} = Reports.action(report, socket.assigns.moderator)
38 94 socket |> reload.() |> put_flash(:info, "Report marked handled.")
@@ -41,14 +97,7 @@ defmodule GitGudWeb.ModerationActions do
41 97 end
42 98 end
43 99
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
100 + defp do_moderate_report(socket, id, reload) do
52 101 moderator = socket.assigns.moderator
53 102
54 103 with {:ok, report} <- scoped_report(socket, id),
@@ -60,8 +109,7 @@ defmodule GitGudWeb.ModerationActions do
60 109 end
61 110 end
62 111
63 − @doc "Ban the author of a reported item, straight from the queue."
64 − def ban_reported_author(socket, id, reload) do
112 + defp do_ban_reported_author(socket, id, reload) do
65 113 with {:ok, report} <- scoped_report(socket, id),
66 114 %{author_id: author_id} when is_integer(author_id) <- Reports.preview(report),
67 115 %{} = user <- GitGud.Repo.get(GitGud.Accounts.User, author_id) do
@@ -71,8 +119,7 @@ defmodule GitGudWeb.ModerationActions do
71 119 end
72 120 end
73 121
74 − @doc "Ban someone by handle."
75 − def ban_user(socket, params, reload) do
122 + defp do_ban_user(socket, params, reload) do
76 123 handle = String.trim(params["handle"] || "")
77 124
78 125 case Accounts.get_user_by_handle(handle) do
@@ -113,9 +160,8 @@ defmodule GitGudWeb.ModerationActions do
113 160 end
114 161 end
115 162
116 − @doc "Lift a ban."
117 − def lift_ban(socket, id, reload) do
118 − ban = Moderation.get_ban(id)
163 + defp do_lift_ban(socket, id, reload) do
164 + ban = (id = GitGudWeb.RepoAdmin.parse_id(id)) && Moderation.get_ban(id)
119 165
120 166 if ban && in_scope?(ban, socket.assigns.scope) do
121 167 {:ok, _} = Moderation.lift(ban, socket.assigns.moderator)
@@ -128,21 +174,21 @@ defmodule GitGudWeb.ModerationActions do
128 174 # A moderator may only act on reports in their own scope; the id in
129 175 # the event is not taken on trust.
130 176 defp scoped_report(socket, id) do
131 − report = Reports.get_report!(String.to_integer(id))
177 + report = Reports.get_report(GitGudWeb.RepoAdmin.parse_id(id))
132 178
133 − if report_in_scope?(report, socket.assigns.scope),
179 + if report && report_in_scope?(report, socket.assigns.scope),
134 180 do: {:ok, report},
135 181 else: :error
136 182 end
137 183
138 − defp report_in_scope?(report, %GitGud.Repositories.Repository{id: id}),
184 + defp report_in_scope?(report, %Repository{id: id}),
139 185 do: report.repository_id == id
140 186
141 − defp report_in_scope?(report, %GitGud.Organizations.Organization{id: id}),
187 + defp report_in_scope?(report, %Organization{id: id}),
142 188 do: report.organization_id == id
143 189
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
190 + defp in_scope?(ban, %Repository{id: id}), do: ban.repository_id == id
191 + defp in_scope?(ban, %Organization{id: id}), do: ban.organization_id == id
146 192
147 193 defp blank_to_nil(nil), do: nil
148 194
added lib/git_gud_web/live/org_admin.ex
+37 −0
@@ -0,0 +1,37 @@
1 +defmodule GitGudWeb.OrgAdmin do
2 + @moduledoc """
3 + `GitGudWeb.RepoAdmin`'s counterpart for organization settings pages.
4 +
5 + Each page checks `Organizations.org_admin?/2` in mount; every handler
6 + that changes something goes through `with_org_admin/2`, which re-reads
7 + the organization and the membership rather than trusting the mount —
8 + an admin demoted in another tab keeps no powers in this one.
9 + """
10 +
11 + import Phoenix.LiveView, only: [put_flash: 3]
12 +
13 + alias GitGud.Organizations
14 + alias GitGud.Organizations.Organization
15 +
16 + @doc "Whether the socket's user administers its `:org`, asked now."
17 + def org_admin_now?(%{assigns: %{org: %Organization{id: id}} = assigns}) do
18 + user = assigns[:current_scope] && assigns.current_scope.user
19 +
20 + case user && GitGud.Repo.get(Organization, id) do
21 + %Organization{} = org -> Organizations.org_admin?(org, user)
22 + _ -> false
23 + end
24 + end
25 +
26 + def org_admin_now?(_socket), do: false
27 +
28 + @doc """
29 + Runs `fun` (a handler body returning `{:noreply, socket}`) only if the
30 + socket's user still administers the organization.
31 + """
32 + def with_org_admin(socket, fun) when is_function(fun, 0) do
33 + if org_admin_now?(socket),
34 + do: fun.(),
35 + else: {:noreply, put_flash(socket, :error, "Org admin only.")}
36 + end
37 +end
modified lib/git_gud_web/live/org_live/members.ex
+15 −6

Click to load diff…

modified lib/git_gud_web/live/org_live/settings_profile.ex
+12 −5

Click to load diff…

modified lib/git_gud_web/live/org_live/settings_runners.ex
+30 −12

Click to load diff…

modified lib/git_gud_web/live/pr_live/show.ex
+55 −22

Click to load diff…

modified lib/git_gud_web/live/registry_token_live/index.ex
+24 −15

Click to load diff…

modified lib/git_gud_web/live/repo_live/fork.ex
+4 −1

Click to load diff…

modified lib/git_gud_web/live/repo_live/merge.ex
+5 −1

Click to load diff…

modified lib/git_gud_web/live/repo_live/new.ex
+4 −2

Click to load diff…

modified lib/git_gud_web/live/repo_live/settings.ex
+15 −7

Click to load diff…

modified lib/git_gud_web/live/repo_live/settings_runners.ex
+11 −5

Click to load diff…

modified lib/git_gud_web/live/workflow_live/show.ex
+7 −7

Click to load diff…

added test/git_gud_web/live/repo_auth/page_handlers_auth_test.exs
+334 −0

Click to load diff…

Parents: 49fdeb3