Redesign staff directories and case workflows
This commit is contained in:
parent
9279835243
commit
76e1ab7fd8
|
|
@ -242,6 +242,18 @@ defmodule WhoNeedHelp.Accounts do
|
|||
end
|
||||
end
|
||||
|
||||
def get_user_for_moderation(%Scope{user: actor}, user_id) do
|
||||
with true <- authorized?(actor, :users_view),
|
||||
{:ok, user_id} <- cast_id(user_id) do
|
||||
User
|
||||
|> where([user], user.id == ^user_id)
|
||||
|> preload(:staff_role_assignments)
|
||||
|> Repo.one()
|
||||
else
|
||||
_unauthorized_or_invalid -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def moderate_user(%User{} = moderator, user_id, attrs) do
|
||||
with {:ok, user_id} <- cast_id(user_id),
|
||||
true <- authorized?(moderator, :users_moderate) do
|
||||
|
|
@ -1138,13 +1150,19 @@ defmodule WhoNeedHelp.Accounts do
|
|||
|
||||
defp maybe_user_search(query, value) do
|
||||
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
||||
pattern = "%#{term}%"
|
||||
|
||||
where(
|
||||
query,
|
||||
[user],
|
||||
ilike(user.email, ^pattern) or ilike(user.display_name, ^pattern)
|
||||
)
|
||||
if String.length(term) < 3 do
|
||||
where(query, [user], false)
|
||||
else
|
||||
pattern = "%#{String.downcase(term)}%"
|
||||
|
||||
where(
|
||||
query,
|
||||
[user],
|
||||
fragment("lower(?::text) LIKE ?", user.email, ^pattern) or
|
||||
fragment("lower(?) LIKE ?", user.display_name, ^pattern)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp cast_id(value) do
|
||||
|
|
|
|||
|
|
@ -127,6 +127,22 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
end
|
||||
end
|
||||
|
||||
def get_for_staff(%Scope{user: user}, id) do
|
||||
with true <- Accounts.authorized?(user, :legal_view),
|
||||
{:ok, id} <- Ecto.UUID.cast(id),
|
||||
%Notice{} = notice <-
|
||||
Notice
|
||||
|> visible_to_staff()
|
||||
|> where([notice], notice.id == ^id)
|
||||
|> preload([:requester, :reviewed_by, :assigned_to])
|
||||
|> Repo.one() do
|
||||
{:ok, notice}
|
||||
else
|
||||
false -> {:error, :forbidden}
|
||||
_ -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def moderate(%Scope{user: moderator}, id, attrs) do
|
||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||
true <- Accounts.authorized?(moderator, :legal_manage) do
|
||||
|
|
@ -306,14 +322,24 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
|
||||
defp maybe_search(query, value) do
|
||||
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
||||
pattern = "%#{term}%"
|
||||
|
||||
where(
|
||||
query,
|
||||
[notice],
|
||||
ilike(notice.reference, ^pattern) or ilike(notice.contact_email, ^pattern) or
|
||||
ilike(notice.submitter_name, ^pattern)
|
||||
)
|
||||
if String.length(term) < 3 do
|
||||
where(query, [notice], false)
|
||||
else
|
||||
pattern = "%#{String.downcase(term)}%"
|
||||
|
||||
where(
|
||||
query,
|
||||
[notice],
|
||||
fragment(
|
||||
"lower(coalesce(?, '') || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) LIKE ?",
|
||||
notice.reference,
|
||||
notice.contact_email,
|
||||
notice.submitter_name,
|
||||
^pattern
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_assignment(attrs, permission) do
|
||||
|
|
|
|||
|
|
@ -154,13 +154,7 @@ defmodule WhoNeedHelp.Support do
|
|||
|> before(cursor)
|
||||
|> order_by([request], desc: request.inserted_at, desc: request.id)
|
||||
|> limit(^(limit + 1))
|
||||
|> preload([
|
||||
:requester,
|
||||
:reviewed_by,
|
||||
:assigned_to,
|
||||
conversation_messages: :sender,
|
||||
status_events: :actor
|
||||
])
|
||||
|> preload([:requester, :assigned_to])
|
||||
|> Repo.all()
|
||||
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||
else
|
||||
|
|
@ -168,6 +162,20 @@ defmodule WhoNeedHelp.Support do
|
|||
end
|
||||
end
|
||||
|
||||
def get_for_staff(%Scope{user: user}, id) do
|
||||
with true <- Accounts.authorized?(user, :support_view),
|
||||
{:ok, id} <- Ecto.UUID.cast(id),
|
||||
%SupportRequest{} = request <-
|
||||
SupportRequest
|
||||
|> where([request], request.id == ^id and not is_nil(request.contact_verified_at))
|
||||
|> Repo.one() do
|
||||
{:ok, preload_conversation(request)}
|
||||
else
|
||||
false -> {:error, :forbidden}
|
||||
_ -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def moderate(%Scope{user: moderator}, id, attrs) do
|
||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||
true <- Accounts.authorized?(moderator, :support_manage) do
|
||||
|
|
@ -595,14 +603,24 @@ defmodule WhoNeedHelp.Support do
|
|||
|
||||
defp maybe_search(query, value) do
|
||||
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
||||
pattern = "%#{term}%"
|
||||
|
||||
where(
|
||||
query,
|
||||
[request],
|
||||
ilike(request.reference, ^pattern) or ilike(request.contact_email, ^pattern) or
|
||||
ilike(request.subject, ^pattern)
|
||||
)
|
||||
if String.length(term) < 3 do
|
||||
where(query, [request], false)
|
||||
else
|
||||
pattern = "%#{String.downcase(term)}%"
|
||||
|
||||
where(
|
||||
query,
|
||||
[request],
|
||||
fragment(
|
||||
"lower(coalesce(?, '') || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) LIKE ?",
|
||||
request.reference,
|
||||
request.contact_email,
|
||||
request.subject,
|
||||
^pattern
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_assignment(attrs, permission) do
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
|||
def shell(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="grid gap-6 lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start">
|
||||
<div class="grid gap-4 lg:grid-cols-[14rem_minmax(0,1fr)] lg:items-start">
|
||||
<details
|
||||
id="staff-mobile-navigation"
|
||||
class="collapse collapse-arrow rounded-2xl border border-base-300 bg-base-100 lg:hidden"
|
||||
|
|
@ -36,7 +36,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
|||
</div>
|
||||
</details>
|
||||
|
||||
<aside class="hidden rounded-3xl border border-base-300 bg-base-100 p-3 lg:sticky lg:top-24 lg:block">
|
||||
<aside class="hidden rounded-2xl border border-base-300 bg-base-100 p-2 lg:sticky lg:top-24 lg:block">
|
||||
<div class="px-3 pb-3 pt-2">
|
||||
<p class="text-xs font-black uppercase tracking-[0.18em] text-primary">
|
||||
{gettext("Staff workspace")}
|
||||
|
|
@ -51,7 +51,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
|||
</aside>
|
||||
|
||||
<div class="min-w-0">
|
||||
<header class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-7">
|
||||
<header class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary/10 text-primary">
|
||||
<.icon name="hero-lock-closed" class="size-5" />
|
||||
|
|
@ -60,7 +60,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
|||
<p class="text-xs font-black uppercase tracking-[0.18em] text-primary">
|
||||
{gettext("Restricted workspace")}
|
||||
</p>
|
||||
<h1 class="mt-1 text-3xl font-black tracking-tight sm:text-4xl">{@title}</h1>
|
||||
<h1 class="mt-1 text-2xl font-black tracking-tight sm:text-3xl">{@title}</h1>
|
||||
<p
|
||||
:if={@description}
|
||||
class="mt-2 max-w-3xl text-sm leading-6 text-base-content/65 sm:text-base"
|
||||
|
|
@ -71,7 +71,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-6 space-y-6">{render_slot(@inner_block)}</div>
|
||||
<div class="mt-4 space-y-4">{render_slot(@inner_block)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
269
lib/who_need_help_web/live/admin_user_live.ex
Normal file
269
lib/who_need_help_web/live/admin_user_live.ex
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
defmodule WhoNeedHelpWeb.AdminUserLive do
|
||||
use WhoNeedHelpWeb, :live_view
|
||||
|
||||
alias WhoNeedHelp.{Accounts, Trust}
|
||||
alias WhoNeedHelp.Accounts.StaffPermissions
|
||||
alias WhoNeedHelpWeb.AdminComponents
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => user_id}, _session, socket) do
|
||||
case Accounts.get_user_for_moderation(socket.assigns.current_scope, user_id) do
|
||||
nil ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, gettext("The selected account is no longer available."))
|
||||
|> push_navigate(to: ~p"/admin/users")}
|
||||
|
||||
user ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Manage user"))
|
||||
|> assign(:roles, StaffPermissions.roles())
|
||||
|> assign_user(user)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("moderate-user", %{"moderation" => params}, socket) do
|
||||
respond(
|
||||
socket,
|
||||
Trust.moderate_user(socket.assigns.current_scope, socket.assigns.user.id, params),
|
||||
gettext("Account status updated.")
|
||||
)
|
||||
end
|
||||
|
||||
def handle_event("set-staff-roles", %{"staff" => params}, socket) do
|
||||
roles = params |> Map.get("roles", []) |> List.wrap() |> Enum.reject(&(&1 == ""))
|
||||
|
||||
respond(
|
||||
socket,
|
||||
Trust.set_staff_roles(socket.assigns.current_scope, socket.assigns.user.id, roles),
|
||||
gettext("Staff roles updated.")
|
||||
)
|
||||
end
|
||||
|
||||
defp respond(socket, {:ok, _record}, message) do
|
||||
case Accounts.get_user_for_moderation(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.user.id
|
||||
) do
|
||||
nil ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("The selected account is no longer available."))
|
||||
|> push_navigate(to: ~p"/admin/users")}
|
||||
|
||||
user ->
|
||||
{:noreply, socket |> put_flash(:info, message) |> assign_user(user)}
|
||||
end
|
||||
end
|
||||
|
||||
defp respond(socket, {:error, reason}, _message),
|
||||
do: {:noreply, put_flash(socket, :error, error_message(reason))}
|
||||
|
||||
defp assign_user(socket, user) do
|
||||
moderation = %{
|
||||
"moderation_status" => to_string(user.moderation_status),
|
||||
"moderation_note" => user.moderation_note || ""
|
||||
}
|
||||
|
||||
socket
|
||||
|> assign(:user, user)
|
||||
|> assign(:moderation_form, to_form(moderation, as: :moderation))
|
||||
|> assign(:staff_form, to_form(%{}, as: :staff))
|
||||
end
|
||||
|
||||
defp error_message(:forbidden),
|
||||
do:
|
||||
gettext(
|
||||
"You do not have permission for this action. Role changes also require a recent sign-in."
|
||||
)
|
||||
|
||||
defp error_message(:cannot_restrict_self),
|
||||
do: gettext("You cannot restrict or suspend your own account.")
|
||||
|
||||
defp error_message(:last_admin),
|
||||
do:
|
||||
gettext("The last active administrator cannot lose administrator access or be restricted.")
|
||||
|
||||
defp error_message(:invalid_roles), do: gettext("One or more staff roles are invalid.")
|
||||
defp error_message(:not_found), do: gettext("The selected account is no longer available.")
|
||||
defp error_message(%Ecto.Changeset{}), do: gettext("Check the status and internal note.")
|
||||
defp error_message(_reason), do: gettext("The account could not be updated.")
|
||||
|
||||
defp role_label(:support), do: gettext("Support")
|
||||
defp role_label(:moderator), do: gettext("Moderator")
|
||||
defp role_label(:legal), do: gettext("Legal")
|
||||
defp role_label(:analyst), do: gettext("Analyst")
|
||||
defp role_label(:admin), do: gettext("Administrator")
|
||||
|
||||
defp role_description(:support), do: gettext("Support conversations and verified support cases")
|
||||
|
||||
defp role_description(:moderator),
|
||||
do: gettext("Reports, safety signals, user restrictions, and categories")
|
||||
|
||||
defp role_description(:legal), do: gettext("Content-removal and legal notice queue")
|
||||
defp role_description(:analyst), do: gettext("Privacy-preserving aggregate product analytics")
|
||||
defp role_description(:admin), do: gettext("All permissions, staff roles, and audit log")
|
||||
|
||||
defp status_label(:active), do: gettext("Active")
|
||||
defp status_label(:restricted), do: gettext("Restricted")
|
||||
defp status_label(:suspended), do: gettext("Suspended")
|
||||
|
||||
defp status_class(:active), do: "badge-success badge-outline"
|
||||
defp status_class(:restricted), do: "badge-warning"
|
||||
defp status_class(:suspended), do: "badge-error"
|
||||
|
||||
defp target_has_staff_roles?(user), do: Accounts.loaded_staff_roles(user) != []
|
||||
|
||||
defp can_moderate?(actor, target) do
|
||||
Accounts.permission?(actor, :users_moderate) and
|
||||
(not target_has_staff_roles?(target) or Accounts.permission?(actor, :staff_manage))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<AdminComponents.shell
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active={:users}
|
||||
title={gettext("Manage user")}
|
||||
description={gettext("Review account access and staff permissions for one user.")}
|
||||
>
|
||||
<.link navigate={~p"/admin/users"} class="btn btn-ghost btn-sm w-fit px-1">
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back to users")}
|
||||
</.link>
|
||||
|
||||
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="truncate text-xl font-black">
|
||||
{@user.display_name || gettext("Unnamed user")}
|
||||
</h2>
|
||||
<span class={["badge badge-sm", status_class(@user.moderation_status)]}>
|
||||
{status_label(@user.moderation_status)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 break-all text-sm text-base-content/60">{@user.email}</p>
|
||||
<p class="mt-2 font-mono text-xs text-base-content/45">{@user.id}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 sm:max-w-sm sm:justify-end">
|
||||
<span :if={Accounts.loaded_staff_roles(@user) == []} class="badge badge-ghost badge-sm">
|
||||
{gettext("Regular user")}
|
||||
</span>
|
||||
<span
|
||||
:for={role <- Accounts.loaded_staff_roles(@user)}
|
||||
class="badge badge-primary badge-outline badge-sm"
|
||||
>
|
||||
{role_label(role)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-2">
|
||||
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-black">{gettext("Account access")}</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
{gettext("Restrict or suspend access and record an internal reason.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={can_moderate?(@current_scope.user, @user)}
|
||||
for={@moderation_form}
|
||||
id={"moderate-user-#{@user.id}"}
|
||||
phx-submit="moderate-user"
|
||||
class="mt-4 space-y-4"
|
||||
>
|
||||
<.input
|
||||
field={@moderation_form[:moderation_status]}
|
||||
type="select"
|
||||
label={gettext("Status")}
|
||||
options={[
|
||||
{gettext("Active"), "active"},
|
||||
{gettext("Restricted"), "restricted"},
|
||||
{gettext("Suspended"), "suspended"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={@moderation_form[:moderation_note]}
|
||||
type="textarea"
|
||||
label={gettext("Internal note")}
|
||||
maxlength="1000"
|
||||
rows="4"
|
||||
/>
|
||||
<button class="btn btn-primary w-full sm:w-auto" phx-disable-with={gettext("Saving…")}>
|
||||
{gettext("Save account status")}
|
||||
</button>
|
||||
</.form>
|
||||
|
||||
<p
|
||||
:if={!can_moderate?(@current_scope.user, @user)}
|
||||
class="mt-4 rounded-xl bg-base-200 p-4 text-sm text-base-content/65"
|
||||
>
|
||||
{gettext("You can view this account but cannot change its access.")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-black">{gettext("Staff roles")}</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
{gettext("A staff member may hold several fixed roles.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={Accounts.permission?(@current_scope.user, :staff_manage)}
|
||||
for={@staff_form}
|
||||
id={"staff-roles-#{@user.id}"}
|
||||
phx-submit="set-staff-roles"
|
||||
class="mt-4"
|
||||
>
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="sr-only">{gettext("Staff roles")}</legend>
|
||||
<input type="hidden" name="staff[roles][]" value="" />
|
||||
<label
|
||||
:for={role <- @roles}
|
||||
class="flex cursor-pointer items-start gap-3 rounded-xl border border-base-300 p-3 transition hover:border-primary/45"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="staff[roles][]"
|
||||
value={Atom.to_string(role)}
|
||||
checked={role in Accounts.loaded_staff_roles(@user)}
|
||||
class="checkbox checkbox-primary mt-0.5"
|
||||
/>
|
||||
<span class="min-w-0">
|
||||
<span class="block font-bold">{role_label(role)}</span>
|
||||
<span class="block text-xs leading-5 text-base-content/55">
|
||||
{role_description(role)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<button
|
||||
class="btn btn-warning mt-4 w-full sm:w-auto"
|
||||
phx-disable-with={gettext("Updating…")}
|
||||
>
|
||||
{gettext("Update staff roles")}
|
||||
</button>
|
||||
</.form>
|
||||
|
||||
<p
|
||||
:if={!Accounts.permission?(@current_scope.user, :staff_manage)}
|
||||
class="mt-4 rounded-xl bg-base-200 p-4 text-sm text-base-content/65"
|
||||
>
|
||||
{gettext("Only administrators can change staff roles.")}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</AdminComponents.shell>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
defmodule WhoNeedHelpWeb.AdminUsersLive do
|
||||
use WhoNeedHelpWeb, :live_view
|
||||
|
||||
alias WhoNeedHelp.{Accounts, Trust}
|
||||
alias WhoNeedHelp.Accounts
|
||||
alias WhoNeedHelp.Accounts.StaffPermissions
|
||||
alias WhoNeedHelpWeb.AdminComponents
|
||||
|
||||
@page_sizes ~w(25 50 100)
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
filters = %{"search" => "", "status" => "", "role" => ""}
|
||||
filters = default_filters()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
@ -15,91 +17,104 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do
|
|||
|> assign(:filters, filters)
|
||||
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||
|> assign(:roles, StaffPermissions.roles())
|
||||
|> assign(:page_cursor, nil)
|
||||
|> assign(:cursor_history, [])
|
||||
|> assign(:page_number, 1)
|
||||
|> assign(:page_size, 50)
|
||||
|> load_users()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter", %{"filters" => filters}, socket) do
|
||||
filters = Map.merge(socket.assigns.filters, filters)
|
||||
def handle_event("filter", %{"filters" => params}, socket) do
|
||||
filters = normalize_filters(params)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:filters, filters)
|
||||
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||
|> assign(:page_cursor, nil)
|
||||
|> assign(:cursor_history, [])
|
||||
|> assign(:page_number, 1)
|
||||
|> assign(:page_size, page_size(filters["limit"]))
|
||||
|> load_users()}
|
||||
end
|
||||
|
||||
def handle_event("load-more", _params, socket) do
|
||||
page =
|
||||
Accounts.paginate_users_for_moderation(
|
||||
socket.assigns.current_scope,
|
||||
user_options(socket, socket.assigns.users_cursor)
|
||||
)
|
||||
def handle_event("next-page", _params, %{assigns: %{users_cursor: nil}} = socket),
|
||||
do: {:noreply, socket}
|
||||
|
||||
existing_ids = MapSet.new(socket.assigns.users, & &1.id)
|
||||
def handle_event("next-page", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:cursor_history, [socket.assigns.page_cursor | socket.assigns.cursor_history])
|
||||
|> assign(:page_cursor, socket.assigns.users_cursor)
|
||||
|> assign(:page_number, socket.assigns.page_number + 1)
|
||||
|> load_users()}
|
||||
end
|
||||
|
||||
def handle_event("previous-page", _params, %{assigns: %{cursor_history: []}} = socket),
|
||||
do: {:noreply, socket}
|
||||
|
||||
def handle_event("previous-page", _params, socket) do
|
||||
[previous_cursor | history] = socket.assigns.cursor_history
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
:users,
|
||||
socket.assigns.users ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
|
||||
)
|
||||
|> assign(:users_cursor, page.next_cursor)}
|
||||
|> assign(:cursor_history, history)
|
||||
|> assign(:page_cursor, previous_cursor)
|
||||
|> assign(:page_number, max(socket.assigns.page_number - 1, 1))
|
||||
|> load_users()}
|
||||
end
|
||||
|
||||
def handle_event("moderate-user", %{"id" => id, "moderation" => params}, socket) do
|
||||
respond(
|
||||
socket,
|
||||
Trust.moderate_user(socket.assigns.current_scope, id, params),
|
||||
gettext("Account status updated.")
|
||||
)
|
||||
end
|
||||
|
||||
def handle_event("set-staff-roles", %{"id" => id, "staff" => params}, socket) do
|
||||
roles = params |> Map.get("roles", []) |> List.wrap() |> Enum.reject(&(&1 == ""))
|
||||
|
||||
respond(
|
||||
socket,
|
||||
Trust.set_staff_roles(socket.assigns.current_scope, id, roles),
|
||||
gettext("Staff roles updated.")
|
||||
)
|
||||
end
|
||||
|
||||
defp respond(socket, {:ok, _record}, message),
|
||||
do: {:noreply, socket |> put_flash(:info, message) |> load_users()}
|
||||
|
||||
defp respond(socket, {:error, reason}, _message),
|
||||
do: {:noreply, put_flash(socket, :error, error_message(reason))}
|
||||
|
||||
defp load_users(socket) do
|
||||
page =
|
||||
Accounts.paginate_users_for_moderation(socket.assigns.current_scope, user_options(socket))
|
||||
|
||||
socket |> assign(:users, page.entries) |> assign(:users_cursor, page.next_cursor)
|
||||
end
|
||||
|
||||
defp user_options(socket, cursor \\ nil) do
|
||||
filters = socket.assigns.filters
|
||||
[search: filters["search"], status: filters["status"], role: filters["role"], after: cursor]
|
||||
end
|
||||
|
||||
defp error_message(:forbidden),
|
||||
do:
|
||||
gettext(
|
||||
"You do not have permission for this action. Role changes also require a recent sign-in."
|
||||
Accounts.paginate_users_for_moderation(
|
||||
socket.assigns.current_scope,
|
||||
user_options(socket)
|
||||
)
|
||||
|
||||
defp error_message(:cannot_restrict_self),
|
||||
do: gettext("You cannot restrict or suspend your own account.")
|
||||
socket
|
||||
|> assign(:users, page.entries)
|
||||
|> assign(:users_cursor, page.next_cursor)
|
||||
end
|
||||
|
||||
defp error_message(:last_admin),
|
||||
do:
|
||||
gettext("The last active administrator cannot lose administrator access or be restricted.")
|
||||
defp user_options(socket) do
|
||||
filters = socket.assigns.filters
|
||||
|
||||
defp error_message(:invalid_roles), do: gettext("One or more staff roles are invalid.")
|
||||
defp error_message(:not_found), do: gettext("The selected account is no longer available.")
|
||||
defp error_message(%Ecto.Changeset{}), do: gettext("Check the status and internal note.")
|
||||
defp error_message(_reason), do: gettext("The account could not be updated.")
|
||||
[
|
||||
search: filters["search"],
|
||||
status: filters["status"],
|
||||
role: filters["role"],
|
||||
after: socket.assigns.page_cursor,
|
||||
limit: socket.assigns.page_size
|
||||
]
|
||||
end
|
||||
|
||||
defp default_filters,
|
||||
do: %{"search" => "", "status" => "", "role" => "", "limit" => "50"}
|
||||
|
||||
defp normalize_filters(params) do
|
||||
%{
|
||||
"search" => params |> Map.get("search", "") |> String.trim() |> String.slice(0, 160),
|
||||
"status" => normalize_choice(params["status"], ~w(active restricted suspended)),
|
||||
"role" =>
|
||||
normalize_choice(
|
||||
params["role"],
|
||||
["user" | Enum.map(StaffPermissions.roles(), &Atom.to_string/1)]
|
||||
),
|
||||
"limit" => normalize_choice(params["limit"], @page_sizes, "50")
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_choice(value, choices, fallback \\ "") do
|
||||
if value in choices, do: value, else: fallback
|
||||
end
|
||||
|
||||
defp page_size(value) do
|
||||
case Integer.parse(value || "") do
|
||||
{size, ""} when size in [25, 50, 100] -> size
|
||||
_invalid -> 50
|
||||
end
|
||||
end
|
||||
|
||||
defp role_label(:support), do: gettext("Support")
|
||||
defp role_label(:moderator), do: gettext("Moderator")
|
||||
|
|
@ -107,24 +122,34 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do
|
|||
defp role_label(:analyst), do: gettext("Analyst")
|
||||
defp role_label(:admin), do: gettext("Administrator")
|
||||
|
||||
defp role_description(:support), do: gettext("Support conversations and verified support cases")
|
||||
defp status_label(:active), do: gettext("Active")
|
||||
defp status_label(:restricted), do: gettext("Restricted")
|
||||
defp status_label(:suspended), do: gettext("Suspended")
|
||||
|
||||
defp role_description(:moderator),
|
||||
do: gettext("Reports, safety signals, user restrictions, and categories")
|
||||
defp account_name(user), do: user.display_name || gettext("Unnamed user")
|
||||
|
||||
defp role_description(:legal), do: gettext("Content-removal and legal notice queue")
|
||||
defp role_description(:analyst), do: gettext("Privacy-preserving aggregate product analytics")
|
||||
defp role_description(:admin), do: gettext("All permissions, staff roles, and audit log")
|
||||
defp joined_on(%DateTime{} = inserted_at), do: Calendar.strftime(inserted_at, "%Y-%m-%d")
|
||||
|
||||
defp target_has_staff_roles?(user), do: Accounts.loaded_staff_roles(user) != []
|
||||
defp result_range([], _page_number, _page_size), do: {0, 0}
|
||||
|
||||
defp can_moderate?(actor, target) do
|
||||
Accounts.permission?(actor, :users_moderate) and
|
||||
(not target_has_staff_roles?(target) or Accounts.permission?(actor, :staff_manage))
|
||||
defp result_range(users, page_number, page_size) do
|
||||
first = (page_number - 1) * page_size + 1
|
||||
{first, first + length(users) - 1}
|
||||
end
|
||||
|
||||
defp status_class(:active), do: "badge-success badge-outline"
|
||||
defp status_class(:restricted), do: "badge-warning"
|
||||
defp status_class(:suspended), do: "badge-error"
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns =
|
||||
assign(
|
||||
assigns,
|
||||
:result_range,
|
||||
result_range(assigns.users, assigns.page_number, assigns.page_size)
|
||||
)
|
||||
|
||||
~H"""
|
||||
<AdminComponents.shell
|
||||
flash={@flash}
|
||||
|
|
@ -133,7 +158,7 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do
|
|||
title={gettext("Users and staff access")}
|
||||
description={
|
||||
gettext(
|
||||
"Search accounts, restrict or suspend access, and combine fixed staff roles without creating ambiguous custom permissions."
|
||||
"Search accounts, review access, and manage fixed staff roles from a dedicated account page."
|
||||
)
|
||||
}
|
||||
>
|
||||
|
|
@ -141,13 +166,14 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do
|
|||
for={@filter_form}
|
||||
id="admin-user-filters"
|
||||
phx-change="filter"
|
||||
class="grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-[minmax(0,1fr)_12rem_12rem]"
|
||||
class="grid gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 md:grid-cols-[minmax(16rem,1fr)_11rem_11rem_8rem]"
|
||||
>
|
||||
<.input
|
||||
field={@filter_form[:search]}
|
||||
type="search"
|
||||
label={gettext("Search users")}
|
||||
placeholder={gettext("Email or display name")}
|
||||
minlength="3"
|
||||
phx-debounce="300"
|
||||
/>
|
||||
<.input
|
||||
|
|
@ -170,123 +196,156 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do
|
|||
Enum.map(@roles, &{role_label(&1), Atom.to_string(&1)})
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@filter_form[:limit]}
|
||||
type="select"
|
||||
label={gettext("Rows")}
|
||||
options={[{"25", "25"}, {"50", "50"}, {"100", "100"}]}
|
||||
/>
|
||||
</.form>
|
||||
|
||||
<div class="space-y-4">
|
||||
<article
|
||||
:for={user <- @users}
|
||||
class="rounded-3xl border border-base-300 bg-base-100 p-4 sm:p-6"
|
||||
>
|
||||
<% status_form =
|
||||
to_form(
|
||||
%{
|
||||
"moderation_status" => to_string(user.moderation_status),
|
||||
"moderation_note" => user.moderation_note || ""
|
||||
},
|
||||
as: :moderation,
|
||||
id: "status-#{user.id}"
|
||||
) %>
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="truncate text-lg font-black">{user.display_name}</h2>
|
||||
<span class={[
|
||||
"badge",
|
||||
user.moderation_status == :active && "badge-success badge-outline",
|
||||
user.moderation_status == :restricted && "badge-warning",
|
||||
user.moderation_status == :suspended && "badge-error"
|
||||
]}>{user.moderation_status}</span>
|
||||
</div>
|
||||
<p class="mt-1 break-all text-sm text-base-content/60">{user.email}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<span :if={Accounts.loaded_staff_roles(user) == []} class="badge badge-ghost">{gettext(
|
||||
"Regular user"
|
||||
)}</span>
|
||||
<span
|
||||
:for={role <- Accounts.loaded_staff_roles(user)}
|
||||
class="badge badge-primary badge-outline"
|
||||
>{role_label(role)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={can_moderate?(@current_scope.user, user)}
|
||||
for={status_form}
|
||||
id={"moderate-user-#{user.id}"}
|
||||
phx-submit="moderate-user"
|
||||
phx-value-id={user.id}
|
||||
class="grid min-w-0 gap-3 sm:grid-cols-[11rem_minmax(14rem,1fr)_auto] xl:w-[42rem]"
|
||||
>
|
||||
<.input
|
||||
field={status_form[:moderation_status]}
|
||||
type="select"
|
||||
label={gettext("Status")}
|
||||
options={[
|
||||
{gettext("Active"), "active"},
|
||||
{gettext("Restricted"), "restricted"},
|
||||
{gettext("Suspended"), "suspended"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={status_form[:moderation_note]}
|
||||
label={gettext("Internal note")}
|
||||
maxlength="1000"
|
||||
/>
|
||||
<button class="btn btn-primary self-end" phx-disable-with={gettext("Saving…")}>{gettext(
|
||||
"Save"
|
||||
)}</button>
|
||||
</.form>
|
||||
<section class="overflow-hidden rounded-2xl border border-base-300 bg-base-100">
|
||||
<div class="flex flex-col gap-2 border-b border-base-300 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div aria-live="polite">
|
||||
<p class="font-bold">{gettext("User directory")}</p>
|
||||
<p class="text-sm text-base-content/60">
|
||||
<%= if String.length(@filters["search"]) in 1..2 do %>
|
||||
{gettext("Enter at least 3 characters to search.")}
|
||||
<% else %>
|
||||
<%= if @users == [] do %>
|
||||
{gettext("No users match these filters.")}
|
||||
<% else %>
|
||||
{gettext("Showing %{first}–%{last}",
|
||||
first: elem(@result_range, 0),
|
||||
last: elem(@result_range, 1)
|
||||
)}
|
||||
<% end %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-base-content/60">
|
||||
{gettext("Page %{page}", page: @page_number)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={Accounts.permission?(@current_scope.user, :staff_manage)}
|
||||
for={to_form(%{}, as: :staff, id: "roles-#{user.id}")}
|
||||
id={"staff-roles-#{user.id}"}
|
||||
phx-submit="set-staff-roles"
|
||||
phx-value-id={user.id}
|
||||
class="mt-5 border-t border-base-300 pt-5"
|
||||
>
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<fieldset class="grid min-w-0 flex-1 gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<legend class="sr-only">{gettext("Staff roles")}</legend>
|
||||
<input type="hidden" name="staff[roles][]" value="" />
|
||||
<label
|
||||
:for={role <- @roles}
|
||||
class="flex cursor-pointer items-start gap-3 rounded-2xl border border-base-300 p-3 hover:border-primary/40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="staff[roles][]"
|
||||
value={Atom.to_string(role)}
|
||||
checked={role in Accounts.loaded_staff_roles(user)}
|
||||
class="checkbox checkbox-primary mt-0.5"
|
||||
/>
|
||||
<span class="min-w-0">
|
||||
<span class="block font-bold">{role_label(role)}</span>
|
||||
<span class="mt-0.5 block text-xs leading-5 text-base-content/55">{role_description(
|
||||
role
|
||||
)}</span>
|
||||
<div class="hidden overflow-x-auto md:block">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{gettext("User")}</th>
|
||||
<th>{gettext("Status")}</th>
|
||||
<th>{gettext("Roles")}</th>
|
||||
<th>{gettext("Joined")}</th>
|
||||
<th class="text-right"><span class="sr-only">{gettext("Actions")}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={user <- @users} id={"admin-user-row-#{user.id}"}>
|
||||
<td class="min-w-64">
|
||||
<p class="max-w-80 truncate font-bold" title={account_name(user)}>
|
||||
{account_name(user)}
|
||||
</p>
|
||||
<p class="max-w-80 truncate text-xs text-base-content/55" title={user.email}>
|
||||
{user.email}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<span class={["badge badge-sm", status_class(user.moderation_status)]}>
|
||||
{status_label(user.moderation_status)}
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<button class="btn btn-warning shrink-0" phx-disable-with={gettext("Updating…")}>{gettext(
|
||||
"Update roles"
|
||||
)}</button>
|
||||
</td>
|
||||
<td class="max-w-72">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
:if={Accounts.loaded_staff_roles(user) == []}
|
||||
class="badge badge-ghost badge-sm"
|
||||
>
|
||||
{gettext("Regular user")}
|
||||
</span>
|
||||
<span
|
||||
:for={role <- Accounts.loaded_staff_roles(user)}
|
||||
class="badge badge-primary badge-outline badge-sm"
|
||||
>
|
||||
{role_label(role)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap text-sm text-base-content/65">
|
||||
{joined_on(user.inserted_at)}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<.link
|
||||
navigate={~p"/admin/users/#{user.id}"}
|
||||
class="btn btn-outline btn-sm whitespace-nowrap"
|
||||
aria-label={gettext("Manage %{name}", name: account_name(user))}
|
||||
>
|
||||
{gettext("Manage")}
|
||||
<.icon name="hero-chevron-right" class="size-4" />
|
||||
</.link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-base-300 md:hidden">
|
||||
<article :for={user <- @users} id={"admin-user-card-#{user.id}"} class="p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-bold">{account_name(user)}</p>
|
||||
<p class="truncate text-sm text-base-content/55">{user.email}</p>
|
||||
</div>
|
||||
<span class={["badge badge-sm shrink-0", status_class(user.moderation_status)]}>
|
||||
{status_label(user.moderation_status)}
|
||||
</span>
|
||||
</div>
|
||||
</.form>
|
||||
</article>
|
||||
<div class="mt-3 flex flex-wrap gap-1">
|
||||
<span :if={Accounts.loaded_staff_roles(user) == []} class="badge badge-ghost badge-sm">
|
||||
{gettext("Regular user")}
|
||||
</span>
|
||||
<span
|
||||
:for={role <- Accounts.loaded_staff_roles(user)}
|
||||
class="badge badge-primary badge-outline badge-sm"
|
||||
>
|
||||
{role_label(role)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-base-content/55">
|
||||
{gettext("Joined %{date}", date: joined_on(user.inserted_at))}
|
||||
</span>
|
||||
<.link navigate={~p"/admin/users/#{user.id}"} class="btn btn-outline btn-sm">
|
||||
{gettext("Manage")}
|
||||
</.link>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p
|
||||
:if={@users == []}
|
||||
class="rounded-3xl border border-dashed border-base-300 p-10 text-center text-base-content/60"
|
||||
<nav
|
||||
aria-label={gettext("User directory pages")}
|
||||
class="flex items-center justify-between gap-3 rounded-2xl border border-base-300 bg-base-100 p-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="previous-page"
|
||||
disabled={@cursor_history == []}
|
||||
class="btn btn-outline btn-sm"
|
||||
>
|
||||
{gettext("No users match these filters.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button :if={@users_cursor} phx-click="load-more" class="btn btn-outline">{gettext(
|
||||
"Load more users"
|
||||
)}</button>
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Previous")}
|
||||
</button>
|
||||
<span class="text-sm font-semibold">{gettext("Page %{page}", page: @page_number)}</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="next-page"
|
||||
disabled={is_nil(@users_cursor)}
|
||||
class="btn btn-outline btn-sm"
|
||||
>
|
||||
{gettext("Next")}
|
||||
<.icon name="hero-arrow-right" class="size-4" />
|
||||
</button>
|
||||
</nav>
|
||||
</AdminComponents.shell>
|
||||
"""
|
||||
end
|
||||
|
|
|
|||
439
lib/who_need_help_web/live/support_operation_live.ex
Normal file
439
lib/who_need_help_web/live/support_operation_live.ex
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
defmodule WhoNeedHelpWeb.SupportOperationLive do
|
||||
use WhoNeedHelpWeb, :live_view
|
||||
|
||||
alias WhoNeedHelp.{Accounts, ContentRemoval, Support}
|
||||
alias WhoNeedHelpWeb.AdminComponents
|
||||
|
||||
@impl true
|
||||
def mount(%{"queue" => queue, "id" => id}, _session, socket) do
|
||||
with {:ok, queue} <- parse_queue(queue),
|
||||
{:ok, record} <- get_record(socket.assigns.current_scope, queue, id) do
|
||||
if connected?(socket) and queue == :support, do: Support.subscribe_request(record.id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Operations case"))
|
||||
|> assign(:queue, queue)
|
||||
|> assign(:support_assignees, staff_assignees(socket, :support_manage))
|
||||
|> assign(:legal_assignees, staff_assignees(socket, :legal_manage))
|
||||
|> assign(:deletion_assessment, nil)
|
||||
|> assign_record(record)}
|
||||
else
|
||||
_error ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, gettext("The selected record is no longer available."))
|
||||
|> push_navigate(to: ~p"/support/operations")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:support_request_updated, request_id}, socket) do
|
||||
if socket.assigns.queue == :support and socket.assigns.record.id == request_id do
|
||||
case get_record(socket.assigns.current_scope, :support, request_id) do
|
||||
{:ok, record} -> {:noreply, assign_record(socket, record)}
|
||||
_error -> {:noreply, push_navigate(socket, to: ~p"/support/operations?queue=support")}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("moderate-support", %{"moderation" => params}, socket) do
|
||||
respond(
|
||||
socket,
|
||||
Support.moderate(socket.assigns.current_scope, socket.assigns.record.id, params),
|
||||
gettext("Support request updated.")
|
||||
)
|
||||
end
|
||||
|
||||
def handle_event("moderate-removal", %{"moderation" => params}, socket) do
|
||||
respond(
|
||||
socket,
|
||||
ContentRemoval.moderate(socket.assigns.current_scope, socket.assigns.record.id, params),
|
||||
gettext("Removal notice updated.")
|
||||
)
|
||||
end
|
||||
|
||||
def handle_event("assess-deletion", _params, socket) do
|
||||
case Support.deletion_assessment(socket.assigns.current_scope, socket.assigns.record.id) do
|
||||
{:ok, assessment} -> {:noreply, assign(socket, :deletion_assessment, assessment)}
|
||||
{:error, reason} -> {:noreply, put_flash(socket, :error, error_message(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
defp respond(socket, {:ok, _record}, message) do
|
||||
case get_record(socket.assigns.current_scope, socket.assigns.queue, socket.assigns.record.id) do
|
||||
{:ok, record} -> {:noreply, socket |> put_flash(:info, message) |> assign_record(record)}
|
||||
_error -> {:noreply, push_navigate(socket, to: queue_path(socket.assigns.queue))}
|
||||
end
|
||||
end
|
||||
|
||||
defp respond(socket, {:error, reason}, _message),
|
||||
do: {:noreply, put_flash(socket, :error, error_message(reason))}
|
||||
|
||||
defp parse_queue("support"), do: {:ok, :support}
|
||||
defp parse_queue("legal"), do: {:ok, :legal}
|
||||
defp parse_queue(_queue), do: {:error, :not_found}
|
||||
|
||||
defp get_record(scope, :support, id), do: Support.get_for_staff(scope, id)
|
||||
defp get_record(scope, :legal, id), do: ContentRemoval.get_for_staff(scope, id)
|
||||
|
||||
defp assign_record(socket, record) do
|
||||
form =
|
||||
case socket.assigns.queue do
|
||||
:support ->
|
||||
%{
|
||||
"status" => to_string(record.status),
|
||||
"assigned_to_id" => record.assigned_to_id || "",
|
||||
"response" => ""
|
||||
}
|
||||
|
||||
:legal ->
|
||||
%{
|
||||
"status" => to_string(record.status),
|
||||
"assigned_to_id" => record.assigned_to_id || "",
|
||||
"resolution_note" => record.resolution_note || ""
|
||||
}
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:record, record)
|
||||
|> assign(:moderation_form, to_form(form, as: :moderation))
|
||||
end
|
||||
|
||||
defp staff_assignees(socket, permission) do
|
||||
if Accounts.permission?(socket.assigns.current_scope.user, permission) do
|
||||
Accounts.list_staff_for_permission(permission)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp assignment_options(users) do
|
||||
[{gettext("Unassigned"), ""} | Enum.map(users, &{&1.display_name || &1.email, &1.id})]
|
||||
end
|
||||
|
||||
defp queue_path(:support), do: ~p"/support/operations?queue=support"
|
||||
defp queue_path(:legal), do: ~p"/support/operations?queue=legal"
|
||||
|
||||
defp error_message(:forbidden), do: gettext("You do not have permission for this action.")
|
||||
defp error_message(:not_found), do: gettext("The selected record is no longer available.")
|
||||
defp error_message(:not_deletion_request), do: gettext("This is not an account-deletion case.")
|
||||
defp error_message(:account_not_linked), do: gettext("The request is not linked to an account.")
|
||||
defp error_message(:contact_not_verified), do: gettext("Verify the requester contact first.")
|
||||
defp error_message(:account_not_found), do: gettext("The linked account no longer exists.")
|
||||
|
||||
defp error_message(:invalid_assignee),
|
||||
do: gettext("The selected assignee cannot manage this queue.")
|
||||
|
||||
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
|
||||
defp error_message(_reason), do: gettext("Could not update the request. Please try again.")
|
||||
|
||||
defp support_kind(:account_access), do: gettext("Account access")
|
||||
defp support_kind(:technical_issue), do: gettext("Technical issue")
|
||||
defp support_kind(:safety_concern), do: gettext("Safety concern")
|
||||
defp support_kind(:moderation_appeal), do: gettext("Moderation appeal")
|
||||
defp support_kind(:account_deletion), do: gettext("Account deletion")
|
||||
defp support_kind(:data_export), do: gettext("Data export")
|
||||
defp support_kind(:privacy_request), do: gettext("Privacy request")
|
||||
defp support_kind(:other), do: gettext("Other")
|
||||
defp support_kind(value), do: to_string(value)
|
||||
|
||||
defp removal_category(:illegal_content), do: gettext("Illegal content")
|
||||
defp removal_category(:privacy_violation), do: gettext("Privacy violation")
|
||||
defp removal_category(:copyright), do: gettext("Copyright")
|
||||
defp removal_category(:impersonation), do: gettext("Impersonation")
|
||||
|
||||
defp removal_category(:non_consensual_intimate_media),
|
||||
do: gettext("Non-consensual intimate image or video")
|
||||
|
||||
defp removal_category(:child_sexual_abuse_material),
|
||||
do: gettext("Sexual material involving a minor")
|
||||
|
||||
defp removal_category(:threat_to_life_or_safety), do: gettext("Threat to life or safety")
|
||||
defp removal_category(:other), do: gettext("Other")
|
||||
defp removal_category(value), do: to_string(value)
|
||||
|
||||
defp status_label(value), do: value |> to_string() |> String.replace("_", " ")
|
||||
|
||||
defp status_class(value) when value in [:urgent_review], do: "badge-error"
|
||||
defp status_class(value) when value in [:open, :reviewing], do: "badge-warning badge-outline"
|
||||
defp status_class(value) when value in [:resolved, :actioned], do: "badge-success badge-outline"
|
||||
defp status_class(_value), do: "badge-ghost"
|
||||
|
||||
defp format_datetime(nil), do: gettext("Not available")
|
||||
defp format_datetime(%DateTime{} = value), do: Calendar.strftime(value, "%Y-%m-%d %H:%M UTC")
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<AdminComponents.shell
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active={:support}
|
||||
title={if @queue == :support, do: gettext("Support request"), else: gettext("Legal notice")}
|
||||
description={gettext("Review the complete record and make one audited update at a time.")}
|
||||
>
|
||||
<.link navigate={queue_path(@queue)} class="btn btn-ghost btn-sm w-fit px-1">
|
||||
<.icon name="hero-arrow-left" class="size-4" /> {gettext("Back to queue")}
|
||||
</.link>
|
||||
|
||||
<.support_case
|
||||
:if={@queue == :support}
|
||||
record={@record}
|
||||
form={@moderation_form}
|
||||
assignees={@support_assignees}
|
||||
assessment={@deletion_assessment}
|
||||
/>
|
||||
<.legal_case
|
||||
:if={@queue == :legal}
|
||||
record={@record}
|
||||
form={@moderation_form}
|
||||
assignees={@legal_assignees}
|
||||
/>
|
||||
</AdminComponents.shell>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :record, :any, required: true
|
||||
attr :form, :any, required: true
|
||||
attr :assignees, :list, required: true
|
||||
attr :assessment, :any, default: nil
|
||||
|
||||
defp support_case(assigns) do
|
||||
~H"""
|
||||
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-primary">{@record.reference}</span><span class="badge badge-outline">{support_kind(
|
||||
@record.kind
|
||||
)}</span><span class={["badge", status_class(@record.status)]}>{status_label(
|
||||
@record.status
|
||||
)}</span>
|
||||
</div><h2 class="mt-3 text-xl font-black">{@record.subject}</h2><p class="mt-1 break-all text-sm text-base-content/60">
|
||||
{@record.contact_email}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/60 sm:text-right">
|
||||
<p>{gettext("Received")}</p><p class="font-semibold text-base-content">
|
||||
{format_datetime(@record.inserted_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 whitespace-pre-wrap text-sm leading-6">{@record.details}</p>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(22rem,0.7fr)] xl:items-start">
|
||||
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
|
||||
<h2 class="text-lg font-black">{gettext("Conversation")}</h2>
|
||||
<div class="mt-3 space-y-2">
|
||||
<article
|
||||
:for={message <- @record.conversation_messages}
|
||||
class={[
|
||||
"rounded-xl border p-3 text-sm",
|
||||
message.sender_role == :requester && "border-primary/25 bg-primary/5",
|
||||
message.sender_role == :staff && "border-base-300 bg-base-100"
|
||||
]}
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<strong>{if message.sender_role == :staff,
|
||||
do: gettext("Support team"),
|
||||
else: gettext("Requester")}</strong><time class="text-xs text-base-content/55">{format_datetime(
|
||||
message.inserted_at
|
||||
)}</time>
|
||||
</div><p class="mt-1 whitespace-pre-wrap">{message.body}</p>
|
||||
</article>
|
||||
<p
|
||||
:if={@record.conversation_messages == []}
|
||||
class="rounded-xl bg-base-200 p-4 text-sm text-base-content/55"
|
||||
>
|
||||
{gettext("No conversation messages yet.")}
|
||||
</p>
|
||||
</div>
|
||||
<details class="mt-4 rounded-xl border border-base-300 p-3">
|
||||
<summary class="cursor-pointer font-semibold">{gettext("Status history")}</summary><ol class="mt-2 space-y-1 text-xs text-base-content/65">
|
||||
<li :for={event <- @record.status_events}>
|
||||
{status_label(event.to_status)} · {event.actor_role} · {format_datetime(
|
||||
event.inserted_at
|
||||
)}
|
||||
</li><li :if={@record.status_events == []}>
|
||||
{gettext("No status history is available for this older case.")}
|
||||
</li>
|
||||
</ol>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<div class="space-y-4 xl:sticky xl:top-24">
|
||||
<section
|
||||
:if={@record.kind == :account_deletion}
|
||||
class="rounded-2xl border border-warning/30 bg-warning/5 p-4"
|
||||
>
|
||||
<h2 class="font-black">{gettext("Account deletion preflight")}</h2>
|
||||
<button type="button" phx-click="assess-deletion" class="btn btn-outline btn-sm mt-3">{gettext(
|
||||
"Run deletion preflight"
|
||||
)}</button>
|
||||
<div :if={@assessment} class="mt-3 space-y-2 text-sm">
|
||||
<p class="font-semibold">
|
||||
{if @assessment.technically_idle,
|
||||
do: gettext("No active product workflow was found."),
|
||||
else: gettext("Active or unresolved product state must be handled first.")}
|
||||
</p><ul :if={@assessment.blockers != []} class="list-disc pl-5">
|
||||
<li :for={blocker <- @assessment.blockers}>{blocker.kind}: {blocker.count}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
id={"support-moderation-#{@record.id}"}
|
||||
phx-submit="moderate-support"
|
||||
class="rounded-2xl border border-primary/25 bg-base-100 p-4 sm:p-5"
|
||||
>
|
||||
<h2 class="text-lg font-black">{gettext("Operator action")}</h2><p class="mt-1 text-sm text-base-content/60">
|
||||
{gettext("Update status, assignment, and optionally reply.")}
|
||||
</p>
|
||||
<div class="mt-4 space-y-4">
|
||||
<.input
|
||||
field={@form[:status]}
|
||||
type="select"
|
||||
label={gettext("Case status")}
|
||||
options={[
|
||||
{gettext("Open"), "open"},
|
||||
{gettext("Reviewing"), "reviewing"},
|
||||
{gettext("Waiting for requester"), "waiting_for_requester"},
|
||||
{gettext("Resolved"), "resolved"},
|
||||
{gettext("Closed"), "closed"}
|
||||
]}
|
||||
/><.input
|
||||
field={@form[:assigned_to_id]}
|
||||
type="select"
|
||||
label={gettext("Assigned operator")}
|
||||
options={assignment_options(@assignees)}
|
||||
/><.input
|
||||
field={@form[:response]}
|
||||
type="textarea"
|
||||
rows="5"
|
||||
maxlength="5000"
|
||||
label={gettext("Reply to requester (optional)")}
|
||||
placeholder={gettext("Write the decision, next step, or information you need…")}
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-base-content/55">
|
||||
{if @record.contact_verified_at,
|
||||
do: gettext("The verified contact will receive a new reply by email."),
|
||||
else: gettext("Email is disabled until contact is verified.")}
|
||||
</p>
|
||||
<.button class="btn btn-primary mt-4 w-full" phx-disable-with={gettext("Saving…")}>{gettext(
|
||||
"Save and notify"
|
||||
)}</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :record, :any, required: true
|
||||
attr :form, :any, required: true
|
||||
attr :assignees, :list, required: true
|
||||
|
||||
defp legal_case(assigns) do
|
||||
~H"""
|
||||
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-error">{@record.reference}</span><span class="badge badge-outline">{status_label(
|
||||
@record.regime
|
||||
)}</span><span class={["badge", status_class(@record.status)]}>{status_label(
|
||||
@record.status
|
||||
)}</span>
|
||||
</div><h2 class="mt-3 text-xl font-black">{removal_category(@record.category)}</h2><p class="mt-1 break-all text-sm text-base-content/60">
|
||||
{@record.contact_email || gettext("No contact email")}
|
||||
</p>
|
||||
</div><div class="text-sm text-base-content/60 sm:text-right">
|
||||
<p>{gettext("Received")}</p><p class="font-semibold text-base-content">
|
||||
{format_datetime(@record.inserted_at)}
|
||||
</p><p :if={@record.response_due_at} class="mt-2 text-error">
|
||||
{gettext("Review by %{time}", time: format_datetime(@record.response_due_at))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="mt-5 grid gap-2 text-sm sm:grid-cols-[10rem_1fr]">
|
||||
<dt class="font-semibold">{gettext("Submitter")}</dt><dd>
|
||||
{@record.submitter_name || gettext("Not provided")}
|
||||
</dd><dt class="font-semibold">{gettext("Content locations")}</dt><dd class="space-y-1">
|
||||
<p
|
||||
:for={url <- WhoNeedHelp.ContentRemoval.Notice.content_urls(@record)}
|
||||
class="break-all font-mono text-xs"
|
||||
>
|
||||
{url}
|
||||
</p>
|
||||
</dd>
|
||||
</dl>
|
||||
<div class="mt-4 space-y-3 text-sm leading-6">
|
||||
<p class="whitespace-pre-wrap">{@record.explanation}</p><p
|
||||
:if={@record.legal_basis not in [nil, ""]}
|
||||
class="whitespace-pre-wrap text-base-content/65"
|
||||
>
|
||||
<strong>{gettext("Stated basis:")}</strong> {@record.legal_basis}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
id={"removal-moderation-#{@record.id}"}
|
||||
phx-submit="moderate-removal"
|
||||
class="rounded-2xl border border-error/25 bg-base-100 p-4 sm:p-5"
|
||||
>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-black">{gettext("Operator action")}</h2><p class="mt-1 text-sm text-base-content/60">
|
||||
{gettext("Record the decision and notify the submitter when contact is available.")}
|
||||
</p>
|
||||
</div><span class="badge badge-error badge-outline">{gettext("Audited update")}</span>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-4 xl:grid-cols-[14rem_14rem_minmax(0,1fr)]">
|
||||
<.input
|
||||
field={@form[:status]}
|
||||
type="select"
|
||||
label={gettext("Case status")}
|
||||
options={[
|
||||
{gettext("Open"), "open"},
|
||||
{gettext("Urgent review"), "urgent_review"},
|
||||
{gettext("Reviewing"), "reviewing"},
|
||||
{gettext("More information needed"), "needs_information"},
|
||||
{gettext("Action taken"), "actioned"},
|
||||
{gettext("Rejected"), "rejected"},
|
||||
{gettext("Closed"), "closed"}
|
||||
]}
|
||||
/><.input
|
||||
field={@form[:assigned_to_id]}
|
||||
type="select"
|
||||
label={gettext("Assigned operator")}
|
||||
options={assignment_options(@assignees)}
|
||||
/><.input
|
||||
field={@form[:resolution_note]}
|
||||
type="textarea"
|
||||
rows="6"
|
||||
maxlength="10000"
|
||||
label={gettext("Decision or information request")}
|
||||
placeholder={gettext("Explain the decision, action, or missing information…")}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-xs text-base-content/55">
|
||||
{if @record.contact_verified_at,
|
||||
do: gettext("The verified contact will receive this update by email."),
|
||||
else: gettext("Email is disabled until contact is verified.")}
|
||||
</p><.button class="btn btn-error w-full sm:w-auto" phx-disable-with={gettext("Saving…")}>{gettext(
|
||||
"Save and notify"
|
||||
)}</.button>
|
||||
</div>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
end
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -186,6 +186,7 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
live_session :staff_users,
|
||||
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :users_view}}] do
|
||||
live "/admin/users", AdminUsersLive, :index
|
||||
live "/admin/users/:id", AdminUserLive, :show
|
||||
end
|
||||
|
||||
live_session :staff_audit,
|
||||
|
|
@ -208,6 +209,7 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
{WhoNeedHelpWeb.UserAuth, {:ensure_any_permission, [:support_view, :legal_view]}}
|
||||
] do
|
||||
live "/support/operations", SupportOperationsLive, :index
|
||||
live "/support/operations/:queue/:id", SupportOperationLive, :show
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
defmodule WhoNeedHelp.Repo.Migrations.AddUserDirectorySearchIndexes do
|
||||
use Ecto.Migration
|
||||
|
||||
@disable_ddl_transaction true
|
||||
@disable_migration_lock true
|
||||
|
||||
def up do
|
||||
execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||
|
||||
create index(:users, ["lower(email::text) gin_trgm_ops"],
|
||||
name: :users_email_trigram_index,
|
||||
using: :gin,
|
||||
concurrently: true
|
||||
)
|
||||
|
||||
create index(:users, ["lower(display_name) gin_trgm_ops"],
|
||||
name: :users_display_name_trigram_index,
|
||||
using: :gin,
|
||||
concurrently: true
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop index(:users, ["lower(display_name) gin_trgm_ops"],
|
||||
name: :users_display_name_trigram_index,
|
||||
concurrently: true
|
||||
)
|
||||
|
||||
drop index(:users, ["lower(email::text) gin_trgm_ops"],
|
||||
name: :users_email_trigram_index,
|
||||
concurrently: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
defmodule WhoNeedHelp.Repo.Migrations.AddOperationsQueueSearchIndexes do
|
||||
use Ecto.Migration
|
||||
|
||||
@disable_ddl_transaction true
|
||||
@disable_migration_lock true
|
||||
|
||||
def up do
|
||||
execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||
|
||||
create index(
|
||||
:support_requests,
|
||||
[
|
||||
"lower(coalesce(reference, '') || ' ' || coalesce(contact_email, '') || ' ' || coalesce(subject, '')) gin_trgm_ops"
|
||||
],
|
||||
name: :support_requests_staff_search_index,
|
||||
using: :gin,
|
||||
concurrently: true
|
||||
)
|
||||
|
||||
create index(
|
||||
:content_removal_notices,
|
||||
[
|
||||
"lower(coalesce(reference, '') || ' ' || coalesce(contact_email, '') || ' ' || coalesce(submitter_name, '')) gin_trgm_ops"
|
||||
],
|
||||
name: :content_removal_notices_staff_search_index,
|
||||
using: :gin,
|
||||
concurrently: true
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop index(
|
||||
:content_removal_notices,
|
||||
[
|
||||
"lower(coalesce(reference, '') || ' ' || coalesce(contact_email, '') || ' ' || coalesce(submitter_name, '')) gin_trgm_ops"
|
||||
],
|
||||
name: :content_removal_notices_staff_search_index,
|
||||
concurrently: true
|
||||
)
|
||||
|
||||
drop index(
|
||||
:support_requests,
|
||||
[
|
||||
"lower(coalesce(reference, '') || ' ' || coalesce(contact_email, '') || ' ' || coalesce(subject, '')) gin_trgm_ops"
|
||||
],
|
||||
name: :support_requests_staff_search_index,
|
||||
concurrently: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -5,6 +5,9 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
import WhoNeedHelp.AccountsFixtures
|
||||
import Swoosh.TestAssertions
|
||||
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelp.Support.SupportRequest
|
||||
|
||||
describe "public support and removal intake" do
|
||||
test "renders each public form", %{conn: conn} do
|
||||
support = html_response(get(conn, ~p"/support"), 200)
|
||||
|
|
@ -273,7 +276,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
{:ok, operator_view, _html} =
|
||||
build_conn()
|
||||
|> log_in_user(moderator)
|
||||
|> live(~p"/support/operations")
|
||||
|> live(~p"/support/operations/support/#{request.id}")
|
||||
|
||||
operator_view
|
||||
|> form("#support-moderation-#{request.id}", %{
|
||||
|
|
@ -344,51 +347,123 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
WhoNeedHelp.Support.access_token(support_request)
|
||||
)
|
||||
|
||||
response =
|
||||
staff_conn = log_in_user(conn, moderator)
|
||||
{:ok, queue_view, response} = live(staff_conn, ~p"/support/operations")
|
||||
|
||||
assert response =~ "Support requests"
|
||||
assert response =~ "Legal and removal"
|
||||
assert has_element?(queue_view, "#support-row-#{support_request.id}")
|
||||
refute response =~ "Operator action"
|
||||
|
||||
queue_view
|
||||
|> element(~s(a[href="/support/operations?queue=legal"]))
|
||||
|> render_click()
|
||||
|
||||
legal_queue = render(queue_view)
|
||||
assert legal_queue =~ "Content removal and TAKE IT DOWN"
|
||||
assert has_element?(queue_view, "#legal-row-#{removal_notice.id}")
|
||||
|
||||
{:ok, removal_view, removal_response} =
|
||||
live(staff_conn, ~p"/support/operations/legal/#{removal_notice.id}")
|
||||
|
||||
removal_document = LazyHTML.from_document(removal_response)
|
||||
|
||||
assert Enum.count(
|
||||
LazyHTML.query(
|
||||
removal_document,
|
||||
"select[name='moderation[status]'] option[value='open'][selected]"
|
||||
)
|
||||
) == 1
|
||||
|
||||
assert has_element?(removal_view, "#removal-moderation-#{removal_notice.id}")
|
||||
|
||||
assert Enum.count(
|
||||
LazyHTML.query(
|
||||
removal_document,
|
||||
"form[id^='removal-moderation-'] textarea[rows='6'][maxlength='10000']"
|
||||
)
|
||||
) == 1
|
||||
|
||||
{:ok, support_view, support_response} =
|
||||
live(staff_conn, ~p"/support/operations/support/#{support_request.id}")
|
||||
|
||||
support_document = LazyHTML.from_document(support_response)
|
||||
|
||||
assert Enum.count(
|
||||
LazyHTML.query(
|
||||
support_document,
|
||||
"select[name='moderation[status]'] option[value='open'][selected]"
|
||||
)
|
||||
) == 1
|
||||
|
||||
assert has_element?(support_view, "#support-moderation-#{support_request.id}")
|
||||
|
||||
assert Enum.count(
|
||||
LazyHTML.query(
|
||||
support_document,
|
||||
"form[id^='support-moderation-'] textarea[rows='5'][maxlength='5000']"
|
||||
)
|
||||
) == 1
|
||||
|
||||
assert support_response =~ "Operator action"
|
||||
assert support_response =~ "Reply to requester (optional)"
|
||||
assert removal_response =~ "Audited update"
|
||||
|
||||
refute support_response =~
|
||||
"This update is recorded, but email is disabled until contact is verified."
|
||||
end
|
||||
|
||||
test "paginates the support directory without expanding case details", %{conn: conn} do
|
||||
moderator = staff_user_fixture([:support])
|
||||
verified_at = DateTime.utc_now(:second)
|
||||
|
||||
for number <- 1..30 do
|
||||
Repo.insert!(%SupportRequest{
|
||||
reference: "SUP-PAGE-#{String.pad_leading(Integer.to_string(number), 3, "0")}",
|
||||
kind: :technical_issue,
|
||||
status: :open,
|
||||
contact_email: "queue-page-#{number}@example.test",
|
||||
subject: "Paginated support request #{number}",
|
||||
details: "This verified fixture exercises keyset pagination in the staff queue.",
|
||||
contact_verified_at: verified_at
|
||||
})
|
||||
end
|
||||
|
||||
{:ok, view, html} =
|
||||
conn
|
||||
|> log_in_user(moderator)
|
||||
|> get(~p"/support/operations")
|
||||
|> html_response(200)
|
||||
|> live(~p"/support/operations")
|
||||
|
||||
assert response =~ "Content removal and TAKE IT DOWN"
|
||||
assert response =~ "General support and data requests"
|
||||
assert html =~ "Showing 1–30"
|
||||
|
||||
document = LazyHTML.from_document(response)
|
||||
assert Enum.count(LazyHTML.query(LazyHTML.from_document(html), "tr[id^='support-row-']")) ==
|
||||
30
|
||||
|
||||
refute html =~ "Operator action"
|
||||
|
||||
view
|
||||
|> form("#support-case-filters", %{"support_filters" => %{"limit" => "25"}})
|
||||
|> render_change()
|
||||
|
||||
first_page = render(view)
|
||||
|
||||
assert first_page =~ "Showing 1–25"
|
||||
|
||||
assert Enum.count(
|
||||
LazyHTML.query(
|
||||
document,
|
||||
"select[id^='removal-'] option[value='open'][selected]"
|
||||
)
|
||||
) == 1
|
||||
LazyHTML.query(LazyHTML.from_document(first_page), "tr[id^='support-row-']")
|
||||
) == 25
|
||||
|
||||
view |> element("button[phx-click='next-page']") |> render_click()
|
||||
second_page = render(view)
|
||||
|
||||
assert second_page =~ "Showing 26–30"
|
||||
|
||||
assert Enum.count(
|
||||
LazyHTML.query(
|
||||
document,
|
||||
"select[id^='support-'] option[value='open'][selected]"
|
||||
)
|
||||
) == 1
|
||||
LazyHTML.query(LazyHTML.from_document(second_page), "tr[id^='support-row-']")
|
||||
) == 5
|
||||
|
||||
assert Enum.count(
|
||||
LazyHTML.query(
|
||||
document,
|
||||
"form[id^='removal-moderation-'] textarea[rows='4'][maxlength='10000']"
|
||||
)
|
||||
) == 1
|
||||
|
||||
assert Enum.count(
|
||||
LazyHTML.query(
|
||||
document,
|
||||
"form[id^='support-moderation-'] textarea[rows='4'][maxlength='5000']"
|
||||
)
|
||||
) == 1
|
||||
|
||||
assert response =~ "Operator action"
|
||||
assert response =~ "Reply to requester (optional)"
|
||||
assert response =~ "Audited update"
|
||||
|
||||
refute response =~
|
||||
"This update is recorded, but email is disabled until contact is verified."
|
||||
view |> element("button[phx-click='previous-page']") |> render_click()
|
||||
assert render(view) =~ "Showing 1–25"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -58,9 +58,14 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
|
|||
|
||||
{:ok, users_view, html} = live(conn, ~p"/admin/users")
|
||||
assert html =~ "Users and staff access"
|
||||
assert has_element?(users_view, "#staff-roles-#{target.id}")
|
||||
assert has_element?(users_view, "#admin-user-row-#{target.id}")
|
||||
assert has_element?(users_view, ~s(a[href="/admin/users/#{target.id}"]))
|
||||
|
||||
users_view
|
||||
{:ok, user_view, html} = live(conn, ~p"/admin/users/#{target.id}")
|
||||
assert html =~ "Account under review"
|
||||
assert has_element?(user_view, "#staff-roles-#{target.id}")
|
||||
|
||||
user_view
|
||||
|> form("#staff-roles-#{target.id}", %{
|
||||
"staff" => %{"roles" => ["support", "moderator"]}
|
||||
})
|
||||
|
|
@ -69,7 +74,7 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
|
|||
updated = target |> Repo.reload!() |> Accounts.preload_staff_roles()
|
||||
assert Accounts.loaded_staff_roles(updated) == [:moderator, :support]
|
||||
|
||||
users_view
|
||||
user_view
|
||||
|> form("#moderate-user-#{target.id}", %{
|
||||
"moderation" => %{
|
||||
"moderation_status" => "suspended",
|
||||
|
|
@ -103,7 +108,7 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
|
|||
admin = staff_user_fixture([:admin], display_name: "Only administrator")
|
||||
conn = log_in_user(conn, admin)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/users")
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/users/#{admin.id}")
|
||||
|
||||
html =
|
||||
view
|
||||
|
|
@ -113,4 +118,39 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
|
|||
assert html =~ "last active administrator"
|
||||
assert Accounts.staff_roles(admin) == [:admin]
|
||||
end
|
||||
|
||||
test "user directory paginates without expanding every account action", %{conn: conn} do
|
||||
admin = staff_user_fixture([:admin], display_name: "Directory administrator")
|
||||
|
||||
for number <- 1..30 do
|
||||
user_fixture(display_name: "Directory user #{number}")
|
||||
end
|
||||
|
||||
{:ok, view, html} = conn |> log_in_user(admin) |> live(~p"/admin/users")
|
||||
|
||||
assert html =~ "Page 1"
|
||||
assert length(has_element_ids(view, "admin-user-row-")) == 31
|
||||
refute html =~ ~s(id="staff-roles-)
|
||||
|
||||
view
|
||||
|> form("#admin-user-filters", %{"filters" => %{"limit" => "25"}})
|
||||
|> render_change()
|
||||
|
||||
assert length(has_element_ids(view, "admin-user-row-")) == 25
|
||||
assert has_element?(view, "button[phx-click=next-page]:not([disabled])")
|
||||
|
||||
view |> element(~s(button[phx-click="next-page"])) |> render_click()
|
||||
assert render(view) =~ "Page 2"
|
||||
assert length(has_element_ids(view, "admin-user-row-")) == 6
|
||||
|
||||
view |> element(~s(button[phx-click="previous-page"])) |> render_click()
|
||||
assert render(view) =~ "Page 1"
|
||||
assert length(has_element_ids(view, "admin-user-row-")) == 25
|
||||
end
|
||||
|
||||
defp has_element_ids(view, prefix) do
|
||||
view
|
||||
|> render()
|
||||
|> then(&Regex.scan(~r/id="#{Regex.escape(prefix)}/, &1))
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user