Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
247
lib/tarakan_web/live/moderation_case_live/show.ex
Normal file
247
lib/tarakan_web/live/moderation_case_live/show.ex
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
defmodule TarakanWeb.ModerationCaseLive.Show do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Moderation
|
||||
alias Tarakan.Policy
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
case Moderation.get_case(socket.assigns.current_scope, id) do
|
||||
{:ok, case_record} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Moderation case ##{case_record.id}")
|
||||
|> assign(:appeal_form, appeal_form())
|
||||
|> assign(:resolution_form, resolution_form())
|
||||
|> assign_case(case_record)}
|
||||
|
||||
{:error, :not_found} ->
|
||||
raise Ecto.NoResultsError, queryable: Tarakan.Moderation.Case
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("assign", _params, socket) do
|
||||
case Moderation.assign(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.case_record
|
||||
) do
|
||||
{:ok, case_record} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_case(case_record)
|
||||
|> put_flash(:info, "Case assigned for independent review.")}
|
||||
|
||||
error ->
|
||||
{:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"resolve",
|
||||
%{"disposition" => disposition, "resolution" => %{"reason" => reason}},
|
||||
socket
|
||||
)
|
||||
when disposition in ["resolved", "dismissed"] do
|
||||
with_recent_auth(socket, fn ->
|
||||
case Moderation.resolve(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.case_record,
|
||||
disposition,
|
||||
reason
|
||||
) do
|
||||
{:ok, case_record} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:resolution_form, resolution_form())
|
||||
|> assign_case(case_record)
|
||||
|> put_flash(:info, resolution_message(disposition))}
|
||||
|
||||
error ->
|
||||
{:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("resolve", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "Choose a valid moderation outcome.")}
|
||||
end
|
||||
|
||||
def handle_event("appeal", %{"appeal" => attrs}, socket) do
|
||||
case Moderation.appeal(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.case_record,
|
||||
attrs
|
||||
) do
|
||||
{:ok, _appeal} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> refresh_case()
|
||||
|> put_flash(:info, "Appeal submitted for independent review.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :appeal_form, to_form(changeset, as: :appeal))}
|
||||
|
||||
error ->
|
||||
{:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("appeal", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "The appeal is incomplete.")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"decide_appeal",
|
||||
%{
|
||||
"decision" => decision,
|
||||
"appeal_decision" => %{"appeal_id" => appeal_id, "reason" => reason}
|
||||
},
|
||||
socket
|
||||
)
|
||||
when decision in ["upheld", "denied"] do
|
||||
with_recent_auth(socket, fn ->
|
||||
with {:ok, appeal_id} <- normalize_id(appeal_id),
|
||||
appeal when not is_nil(appeal) <-
|
||||
Enum.find(socket.assigns.case_record.appeals, &(&1.id == appeal_id)) do
|
||||
case Moderation.decide_appeal(
|
||||
socket.assigns.current_scope,
|
||||
appeal,
|
||||
decision,
|
||||
reason
|
||||
) do
|
||||
{:ok, _appeal} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> refresh_case()
|
||||
|> put_flash(:info, appeal_decision_message(decision))}
|
||||
|
||||
error ->
|
||||
{:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
else
|
||||
_other -> {:noreply, put_flash(socket, :error, "That appeal is no longer available.")}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("decide_appeal", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "Choose a valid appeal decision.")}
|
||||
end
|
||||
|
||||
defp refresh_case(socket) do
|
||||
case Moderation.get_case(socket.assigns.current_scope, socket.assigns.case_record.id) do
|
||||
{:ok, case_record} -> assign_case(socket, case_record)
|
||||
{:error, :not_found} -> push_navigate(socket, to: ~p"/")
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_case(socket, case_record) do
|
||||
socket
|
||||
|> assign(:case_record, case_record)
|
||||
|> assign(:moderator?, moderator?(socket.assigns.current_scope))
|
||||
|> assign(
|
||||
:appeal_decision_forms,
|
||||
Map.new(case_record.appeals, &{&1.id, appeal_decision_form(&1)})
|
||||
)
|
||||
end
|
||||
|
||||
defp moderator?(scope), do: Policy.moderator?(scope) and scope.account_state == "active"
|
||||
|
||||
defp can_assign?(case_record, scope) do
|
||||
moderator?(scope) and independent_from_case?(case_record, scope) and
|
||||
(case_record.status == "open" or
|
||||
(case_record.status == "in_review" and scope.platform_role == "admin"))
|
||||
end
|
||||
|
||||
defp can_resolve?(case_record, scope) do
|
||||
moderator?(scope) and case_record.status == "in_review" and
|
||||
case_record.assigned_to_id == scope.account_id
|
||||
end
|
||||
|
||||
defp can_appeal?(case_record, scope) do
|
||||
case_record.status == "resolved" and case_record.appeals == [] and
|
||||
(case_record.subject_owner_id == scope.account_id or
|
||||
Policy.repository_steward?(scope, case_record))
|
||||
end
|
||||
|
||||
defp can_decide_appeal?(case_record, appeal, scope) do
|
||||
moderator?(scope) and appeal.status == "open" and
|
||||
scope.account_id not in [
|
||||
case_record.reporter_id,
|
||||
case_record.subject_owner_id,
|
||||
case_record.resolved_by_id,
|
||||
appeal.appellant_id
|
||||
]
|
||||
end
|
||||
|
||||
defp independent_from_case?(case_record, scope) do
|
||||
scope.account_id not in [case_record.reporter_id, case_record.subject_owner_id]
|
||||
end
|
||||
|
||||
defp appeal_form, do: to_form(%{"reason" => ""}, as: :appeal)
|
||||
defp resolution_form, do: to_form(%{"reason" => ""}, as: :resolution)
|
||||
|
||||
defp with_recent_auth(socket, fun) do
|
||||
if Accounts.sudo_mode?(socket.assigns.current_scope.account) do
|
||||
fun.()
|
||||
else
|
||||
return_to = ~p"/moderation/cases/#{socket.assigns.case_record.id}"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
"Confirm it's you with a magic link before deciding moderation outcomes (sign-in older than 8 hours)."
|
||||
)
|
||||
|> push_navigate(to: TarakanWeb.AccountAuth.reauth_path(return_to))}
|
||||
end
|
||||
end
|
||||
|
||||
defp appeal_decision_form(appeal) do
|
||||
to_form(%{"appeal_id" => appeal.id, "reason" => ""}, as: :appeal_decision)
|
||||
end
|
||||
|
||||
defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id}
|
||||
|
||||
defp normalize_id(id) when is_binary(id) do
|
||||
case Integer.parse(id) do
|
||||
{parsed, ""} when parsed > 0 -> {:ok, parsed}
|
||||
_other -> {:error, :invalid_id}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_id(_id), do: {:error, :invalid_id}
|
||||
|
||||
defp status_label("in_review"), do: "In review"
|
||||
defp status_label(status), do: String.capitalize(status)
|
||||
|
||||
defp subject_label("review_task"), do: "Job"
|
||||
|
||||
defp subject_label(subject_type),
|
||||
do: subject_type |> String.replace("_", " ") |> String.capitalize()
|
||||
|
||||
defp reason_label(reason), do: reason |> String.replace("_", " ") |> String.capitalize()
|
||||
|
||||
defp resolution_message("resolved"), do: "Case resolved."
|
||||
defp resolution_message("dismissed"), do: "Case dismissed."
|
||||
|
||||
defp appeal_decision_message("upheld"), do: "Appeal upheld and case overturned."
|
||||
defp appeal_decision_message("denied"), do: "Appeal denied."
|
||||
|
||||
defp error_message({:error, :conflict_of_interest}),
|
||||
do: "An independent moderator must handle this action."
|
||||
|
||||
defp error_message({:error, :not_assigned}), do: "This case is assigned to another moderator."
|
||||
defp error_message({:error, :not_appealable}), do: "This case is not eligible for appeal."
|
||||
defp error_message({:error, :already_decided}), do: "That appeal has already been decided."
|
||||
defp error_message({:error, :invalid_reason}), do: "Provide a reason of at least 10 characters."
|
||||
|
||||
defp error_message({:error, :invalid_transition}),
|
||||
do: "That case transition is no longer valid."
|
||||
|
||||
defp error_message({:error, :unauthorized}), do: "You are not authorized for that action."
|
||||
defp error_message({:error, :not_found}), do: "The case is no longer available."
|
||||
defp error_message({:error, _reason}), do: "The moderation action could not be completed."
|
||||
end
|
||||
235
lib/tarakan_web/live/moderation_case_live/show.html.heex
Normal file
235
lib/tarakan_web/live/moderation_case_live/show.html.heex
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs>
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb navigate={if @moderator?, do: ~p"/moderation/queue"}>moderation</:crumb>
|
||||
<:crumb>case/{@case_record.id}</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<main id="moderation-case" class="border-2 border-strong">
|
||||
<header class="border-b-2 border-strong bg-panel px-5 py-6 sm:px-8 sm:py-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Case #{@case_record.id}
|
||||
</h1>
|
||||
<span
|
||||
id="moderation-case-status"
|
||||
class="border border-rule px-3 py-1 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted"
|
||||
>
|
||||
{status_label(@case_record.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-ink-muted">
|
||||
{subject_label(@case_record.subject_type)} #{@case_record.subject_id}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section id="moderation-case-report" class="px-5 py-6 sm:px-8 sm:py-8">
|
||||
<dl class="grid gap-6 sm:grid-cols-[10rem_minmax(0,1fr)]">
|
||||
<div>
|
||||
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Reason
|
||||
</dt>
|
||||
<dd class="mt-2 text-sm text-ink">{reason_label(@case_record.reason)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Report
|
||||
</dt>
|
||||
<dd class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{@case_record.description}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
:if={@case_record.resolution}
|
||||
id="moderation-case-resolution"
|
||||
class="mt-7 border-t border-rule pt-6"
|
||||
>
|
||||
<h2 class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Decision
|
||||
</h2>
|
||||
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{@case_record.resolution}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={can_assign?(@case_record, @current_scope)}
|
||||
id="moderation-case-assignment"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">
|
||||
Independent review
|
||||
</h2>
|
||||
<button
|
||||
id="assign-moderation-case"
|
||||
phx-click="assign"
|
||||
class="clip-notch mt-5 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-click-loading:opacity-60"
|
||||
>
|
||||
Assign to me
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={can_resolve?(@case_record, @current_scope)}
|
||||
id="moderation-case-decision"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Decide case</h2>
|
||||
<.form
|
||||
for={@resolution_form}
|
||||
id="moderation-case-decision-form"
|
||||
phx-submit="resolve"
|
||||
class="mt-5"
|
||||
>
|
||||
<.input
|
||||
field={@resolution_form[:reason]}
|
||||
type="textarea"
|
||||
label="Decision reason"
|
||||
minlength="10"
|
||||
maxlength="2000"
|
||||
rows="5"
|
||||
/>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
id="resolve-moderation-case"
|
||||
type="submit"
|
||||
name="disposition"
|
||||
value="resolved"
|
||||
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Resolve report
|
||||
</button>
|
||||
<button
|
||||
id="dismiss-moderation-case"
|
||||
type="submit"
|
||||
name="disposition"
|
||||
value="dismissed"
|
||||
class="border border-rule px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:border-strong"
|
||||
>
|
||||
Dismiss report
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={can_appeal?(@case_record, @current_scope)}
|
||||
id="moderation-case-appeal"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Appeal decision</h2>
|
||||
<.form for={@appeal_form} id="moderation-appeal-form" phx-submit="appeal" class="mt-5">
|
||||
<.input
|
||||
field={@appeal_form[:reason]}
|
||||
type="textarea"
|
||||
label="Grounds for appeal"
|
||||
minlength="20"
|
||||
maxlength="5000"
|
||||
rows="6"
|
||||
/>
|
||||
<button
|
||||
id="submit-moderation-appeal"
|
||||
type="submit"
|
||||
class="clip-notch mt-4 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-submit-loading:opacity-60"
|
||||
>
|
||||
Submit appeal
|
||||
</button>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={@case_record.appeals != []}
|
||||
id="moderation-case-appeals"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Appeals</h2>
|
||||
<div class="mt-5 grid gap-5">
|
||||
<article
|
||||
:for={appeal <- @case_record.appeals}
|
||||
id={"moderation-appeal-#{appeal.id}"}
|
||||
class="border border-rule p-5"
|
||||
>
|
||||
<% appeal_form = Map.fetch!(@appeal_decision_forms, appeal.id) %>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="font-mono text-xs text-ink-faint">Appeal #{appeal.id}</span>
|
||||
<span class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted">
|
||||
{status_label(appeal.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-4 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{appeal.reason}</p>
|
||||
<div :if={appeal.decision_reason} class="mt-5 border-t border-rule pt-4">
|
||||
<h3 class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Appeal decision
|
||||
</h3>
|
||||
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{appeal.decision_reason}</p>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={can_decide_appeal?(@case_record, appeal, @current_scope)}
|
||||
for={appeal_form}
|
||||
id={"moderation-appeal-decision-form-#{appeal.id}"}
|
||||
phx-submit="decide_appeal"
|
||||
class="mt-5 border-t border-rule pt-5"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name={appeal_form[:appeal_id].name}
|
||||
value={appeal.id}
|
||||
/>
|
||||
<.input
|
||||
field={appeal_form[:reason]}
|
||||
id={"moderation-appeal-decision-reason-#{appeal.id}"}
|
||||
type="textarea"
|
||||
label="Independent decision reason"
|
||||
minlength="10"
|
||||
maxlength="2000"
|
||||
rows="4"
|
||||
/>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
id={"uphold-moderation-appeal-#{appeal.id}"}
|
||||
type="submit"
|
||||
name="decision"
|
||||
value="upheld"
|
||||
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Uphold appeal
|
||||
</button>
|
||||
<button
|
||||
id={"deny-moderation-appeal-#{appeal.id}"}
|
||||
type="submit"
|
||||
name="decision"
|
||||
value="denied"
|
||||
class="border border-rule px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:border-strong"
|
||||
>
|
||||
Deny appeal
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={
|
||||
@moderator? && Ecto.assoc_loaded?(@case_record.actions) && @case_record.actions != []
|
||||
}
|
||||
id="moderation-case-actions"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Case history</h2>
|
||||
<ol class="mt-5 grid gap-4">
|
||||
<li :for={action <- @case_record.actions} id={"moderation-action-#{action.id}"}>
|
||||
<p class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted">
|
||||
{reason_label(action.action)} · {Calendar.strftime(
|
||||
action.inserted_at,
|
||||
"%Y-%m-%d %H:%M UTC"
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-6 text-ink-muted">{action.reason}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
Loading…
Add table
Add a link
Reference in a new issue