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
|
||||||
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
|
def moderate_user(%User{} = moderator, user_id, attrs) do
|
||||||
with {:ok, user_id} <- cast_id(user_id),
|
with {:ok, user_id} <- cast_id(user_id),
|
||||||
true <- authorized?(moderator, :users_moderate) do
|
true <- authorized?(moderator, :users_moderate) do
|
||||||
|
|
@ -1138,14 +1150,20 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
|
|
||||||
defp maybe_user_search(query, value) do
|
defp maybe_user_search(query, value) do
|
||||||
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
||||||
pattern = "%#{term}%"
|
|
||||||
|
if String.length(term) < 3 do
|
||||||
|
where(query, [user], false)
|
||||||
|
else
|
||||||
|
pattern = "%#{String.downcase(term)}%"
|
||||||
|
|
||||||
where(
|
where(
|
||||||
query,
|
query,
|
||||||
[user],
|
[user],
|
||||||
ilike(user.email, ^pattern) or ilike(user.display_name, ^pattern)
|
fragment("lower(?::text) LIKE ?", user.email, ^pattern) or
|
||||||
|
fragment("lower(?) LIKE ?", user.display_name, ^pattern)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp cast_id(value) do
|
defp cast_id(value) do
|
||||||
case Ecto.UUID.cast(value) do
|
case Ecto.UUID.cast(value) do
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,22 @@ defmodule WhoNeedHelp.ContentRemoval do
|
||||||
end
|
end
|
||||||
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
|
def moderate(%Scope{user: moderator}, id, attrs) do
|
||||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||||
true <- Accounts.authorized?(moderator, :legal_manage) do
|
true <- Accounts.authorized?(moderator, :legal_manage) do
|
||||||
|
|
@ -306,14 +322,24 @@ defmodule WhoNeedHelp.ContentRemoval do
|
||||||
|
|
||||||
defp maybe_search(query, value) do
|
defp maybe_search(query, value) do
|
||||||
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
||||||
pattern = "%#{term}%"
|
|
||||||
|
if String.length(term) < 3 do
|
||||||
|
where(query, [notice], false)
|
||||||
|
else
|
||||||
|
pattern = "%#{String.downcase(term)}%"
|
||||||
|
|
||||||
where(
|
where(
|
||||||
query,
|
query,
|
||||||
[notice],
|
[notice],
|
||||||
ilike(notice.reference, ^pattern) or ilike(notice.contact_email, ^pattern) or
|
fragment(
|
||||||
ilike(notice.submitter_name, ^pattern)
|
"lower(coalesce(?, '') || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) LIKE ?",
|
||||||
|
notice.reference,
|
||||||
|
notice.contact_email,
|
||||||
|
notice.submitter_name,
|
||||||
|
^pattern
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_assignment(attrs, permission) do
|
defp normalize_assignment(attrs, permission) do
|
||||||
|
|
|
||||||
|
|
@ -154,13 +154,7 @@ defmodule WhoNeedHelp.Support do
|
||||||
|> before(cursor)
|
|> before(cursor)
|
||||||
|> order_by([request], desc: request.inserted_at, desc: request.id)
|
|> order_by([request], desc: request.inserted_at, desc: request.id)
|
||||||
|> limit(^(limit + 1))
|
|> limit(^(limit + 1))
|
||||||
|> preload([
|
|> preload([:requester, :assigned_to])
|
||||||
:requester,
|
|
||||||
:reviewed_by,
|
|
||||||
:assigned_to,
|
|
||||||
conversation_messages: :sender,
|
|
||||||
status_events: :actor
|
|
||||||
])
|
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
else
|
else
|
||||||
|
|
@ -168,6 +162,20 @@ defmodule WhoNeedHelp.Support do
|
||||||
end
|
end
|
||||||
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
|
def moderate(%Scope{user: moderator}, id, attrs) do
|
||||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||||
true <- Accounts.authorized?(moderator, :support_manage) do
|
true <- Accounts.authorized?(moderator, :support_manage) do
|
||||||
|
|
@ -595,14 +603,24 @@ defmodule WhoNeedHelp.Support do
|
||||||
|
|
||||||
defp maybe_search(query, value) do
|
defp maybe_search(query, value) do
|
||||||
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
||||||
pattern = "%#{term}%"
|
|
||||||
|
if String.length(term) < 3 do
|
||||||
|
where(query, [request], false)
|
||||||
|
else
|
||||||
|
pattern = "%#{String.downcase(term)}%"
|
||||||
|
|
||||||
where(
|
where(
|
||||||
query,
|
query,
|
||||||
[request],
|
[request],
|
||||||
ilike(request.reference, ^pattern) or ilike(request.contact_email, ^pattern) or
|
fragment(
|
||||||
ilike(request.subject, ^pattern)
|
"lower(coalesce(?, '') || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) LIKE ?",
|
||||||
|
request.reference,
|
||||||
|
request.contact_email,
|
||||||
|
request.subject,
|
||||||
|
^pattern
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_assignment(attrs, permission) do
|
defp normalize_assignment(attrs, permission) do
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
||||||
def shell(assigns) do
|
def shell(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<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
|
<details
|
||||||
id="staff-mobile-navigation"
|
id="staff-mobile-navigation"
|
||||||
class="collapse collapse-arrow rounded-2xl border border-base-300 bg-base-100 lg:hidden"
|
class="collapse collapse-arrow rounded-2xl border border-base-300 bg-base-100 lg:hidden"
|
||||||
|
|
@ -36,7 +36,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</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">
|
<div class="px-3 pb-3 pt-2">
|
||||||
<p class="text-xs font-black uppercase tracking-[0.18em] text-primary">
|
<p class="text-xs font-black uppercase tracking-[0.18em] text-primary">
|
||||||
{gettext("Staff workspace")}
|
{gettext("Staff workspace")}
|
||||||
|
|
@ -51,7 +51,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="min-w-0">
|
<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">
|
<div class="flex items-start gap-3">
|
||||||
<span class="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary/10 text-primary">
|
<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" />
|
<.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">
|
<p class="text-xs font-black uppercase tracking-[0.18em] text-primary">
|
||||||
{gettext("Restricted workspace")}
|
{gettext("Restricted workspace")}
|
||||||
</p>
|
</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
|
<p
|
||||||
:if={@description}
|
:if={@description}
|
||||||
class="mt-2 max-w-3xl text-sm leading-6 text-base-content/65 sm:text-base"
|
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>
|
</div>
|
||||||
</header>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</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
|
defmodule WhoNeedHelpWeb.AdminUsersLive do
|
||||||
use WhoNeedHelpWeb, :live_view
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
alias WhoNeedHelp.{Accounts, Trust}
|
alias WhoNeedHelp.Accounts
|
||||||
alias WhoNeedHelp.Accounts.StaffPermissions
|
alias WhoNeedHelp.Accounts.StaffPermissions
|
||||||
alias WhoNeedHelpWeb.AdminComponents
|
alias WhoNeedHelpWeb.AdminComponents
|
||||||
|
|
||||||
|
@page_sizes ~w(25 50 100)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
filters = %{"search" => "", "status" => "", "role" => ""}
|
filters = default_filters()
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|
|
@ -15,91 +17,104 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do
|
||||||
|> assign(:filters, filters)
|
|> assign(:filters, filters)
|
||||||
|> assign(:filter_form, to_form(filters, as: :filters))
|
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||||
|> assign(:roles, StaffPermissions.roles())
|
|> assign(:roles, StaffPermissions.roles())
|
||||||
|
|> assign(:page_cursor, nil)
|
||||||
|
|> assign(:cursor_history, [])
|
||||||
|
|> assign(:page_number, 1)
|
||||||
|
|> assign(:page_size, 50)
|
||||||
|> load_users()}
|
|> load_users()}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("filter", %{"filters" => filters}, socket) do
|
def handle_event("filter", %{"filters" => params}, socket) do
|
||||||
filters = Map.merge(socket.assigns.filters, filters)
|
filters = normalize_filters(params)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:filters, filters)
|
|> assign(:filters, filters)
|
||||||
|> assign(:filter_form, to_form(filters, as: :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()}
|
|> load_users()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("load-more", _params, socket) do
|
def handle_event("next-page", _params, %{assigns: %{users_cursor: nil}} = socket),
|
||||||
page =
|
do: {:noreply, socket}
|
||||||
Accounts.paginate_users_for_moderation(
|
|
||||||
socket.assigns.current_scope,
|
|
||||||
user_options(socket, socket.assigns.users_cursor)
|
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(:cursor_history, history)
|
||||||
:users,
|
|> assign(:page_cursor, previous_cursor)
|
||||||
socket.assigns.users ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
|
|> assign(:page_number, max(socket.assigns.page_number - 1, 1))
|
||||||
)
|
|> load_users()}
|
||||||
|> assign(:users_cursor, page.next_cursor)}
|
|
||||||
end
|
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
|
defp load_users(socket) do
|
||||||
page =
|
page =
|
||||||
Accounts.paginate_users_for_moderation(socket.assigns.current_scope, user_options(socket))
|
Accounts.paginate_users_for_moderation(
|
||||||
|
socket.assigns.current_scope,
|
||||||
socket |> assign(:users, page.entries) |> assign(:users_cursor, page.next_cursor)
|
user_options(socket)
|
||||||
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."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
defp error_message(:cannot_restrict_self),
|
socket
|
||||||
do: gettext("You cannot restrict or suspend your own account.")
|
|> assign(:users, page.entries)
|
||||||
|
|> assign(:users_cursor, page.next_cursor)
|
||||||
|
end
|
||||||
|
|
||||||
defp error_message(:last_admin),
|
defp user_options(socket) do
|
||||||
do:
|
filters = socket.assigns.filters
|
||||||
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.")
|
search: filters["search"],
|
||||||
defp error_message(%Ecto.Changeset{}), do: gettext("Check the status and internal note.")
|
status: filters["status"],
|
||||||
defp error_message(_reason), do: gettext("The account could not be updated.")
|
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(:support), do: gettext("Support")
|
||||||
defp role_label(:moderator), do: gettext("Moderator")
|
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(:analyst), do: gettext("Analyst")
|
||||||
defp role_label(:admin), do: gettext("Administrator")
|
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),
|
defp account_name(user), do: user.display_name || gettext("Unnamed user")
|
||||||
do: gettext("Reports, safety signals, user restrictions, and categories")
|
|
||||||
|
|
||||||
defp role_description(:legal), do: gettext("Content-removal and legal notice queue")
|
defp joined_on(%DateTime{} = inserted_at), do: Calendar.strftime(inserted_at, "%Y-%m-%d")
|
||||||
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 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
|
defp result_range(users, page_number, page_size) do
|
||||||
Accounts.permission?(actor, :users_moderate) and
|
first = (page_number - 1) * page_size + 1
|
||||||
(not target_has_staff_roles?(target) or Accounts.permission?(actor, :staff_manage))
|
{first, first + length(users) - 1}
|
||||||
end
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
|
assigns =
|
||||||
|
assign(
|
||||||
|
assigns,
|
||||||
|
:result_range,
|
||||||
|
result_range(assigns.users, assigns.page_number, assigns.page_size)
|
||||||
|
)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<AdminComponents.shell
|
<AdminComponents.shell
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
|
|
@ -133,7 +158,7 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do
|
||||||
title={gettext("Users and staff access")}
|
title={gettext("Users and staff access")}
|
||||||
description={
|
description={
|
||||||
gettext(
|
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}
|
for={@filter_form}
|
||||||
id="admin-user-filters"
|
id="admin-user-filters"
|
||||||
phx-change="filter"
|
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
|
<.input
|
||||||
field={@filter_form[:search]}
|
field={@filter_form[:search]}
|
||||||
type="search"
|
type="search"
|
||||||
label={gettext("Search users")}
|
label={gettext("Search users")}
|
||||||
placeholder={gettext("Email or display name")}
|
placeholder={gettext("Email or display name")}
|
||||||
|
minlength="3"
|
||||||
phx-debounce="300"
|
phx-debounce="300"
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -170,123 +196,156 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do
|
||||||
Enum.map(@roles, &{role_label(&1), Atom.to_string(&1)})
|
Enum.map(@roles, &{role_label(&1), Atom.to_string(&1)})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</.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
|
<.input
|
||||||
field={status_form[:moderation_status]}
|
field={@filter_form[:limit]}
|
||||||
type="select"
|
type="select"
|
||||||
label={gettext("Status")}
|
label={gettext("Rows")}
|
||||||
options={[
|
options={[{"25", "25"}, {"50", "50"}, {"100", "100"}]}
|
||||||
{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>
|
</.form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<.form
|
<section class="overflow-hidden rounded-2xl border border-base-300 bg-base-100">
|
||||||
:if={Accounts.permission?(@current_scope.user, :staff_manage)}
|
<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">
|
||||||
for={to_form(%{}, as: :staff, id: "roles-#{user.id}")}
|
<div aria-live="polite">
|
||||||
id={"staff-roles-#{user.id}"}
|
<p class="font-bold">{gettext("User directory")}</p>
|
||||||
phx-submit="set-staff-roles"
|
<p class="text-sm text-base-content/60">
|
||||||
phx-value-id={user.id}
|
<%= if String.length(@filters["search"]) in 1..2 do %>
|
||||||
class="mt-5 border-t border-base-300 pt-5"
|
{gettext("Enter at least 3 characters to search.")}
|
||||||
>
|
<% else %>
|
||||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
<%= if @users == [] do %>
|
||||||
<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>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
<button class="btn btn-warning shrink-0" phx-disable-with={gettext("Updating…")}>{gettext(
|
|
||||||
"Update roles"
|
|
||||||
)}</button>
|
|
||||||
</div>
|
|
||||||
</.form>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<p
|
|
||||||
:if={@users == []}
|
|
||||||
class="rounded-3xl border border-dashed border-base-300 p-10 text-center text-base-content/60"
|
|
||||||
>
|
|
||||||
{gettext("No users match these filters.")}
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button :if={@users_cursor} phx-click="load-more" class="btn btn-outline">{gettext(
|
<div class="hidden overflow-x-auto md:block">
|
||||||
"Load more users"
|
<table class="table table-sm">
|
||||||
)}</button>
|
<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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<.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>
|
</AdminComponents.shell>
|
||||||
"""
|
"""
|
||||||
end
|
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,
|
live_session :staff_users,
|
||||||
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :users_view}}] do
|
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :users_view}}] do
|
||||||
live "/admin/users", AdminUsersLive, :index
|
live "/admin/users", AdminUsersLive, :index
|
||||||
|
live "/admin/users/:id", AdminUserLive, :show
|
||||||
end
|
end
|
||||||
|
|
||||||
live_session :staff_audit,
|
live_session :staff_audit,
|
||||||
|
|
@ -208,6 +209,7 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
{WhoNeedHelpWeb.UserAuth, {:ensure_any_permission, [:support_view, :legal_view]}}
|
{WhoNeedHelpWeb.UserAuth, {:ensure_any_permission, [:support_view, :legal_view]}}
|
||||||
] do
|
] do
|
||||||
live "/support/operations", SupportOperationsLive, :index
|
live "/support/operations", SupportOperationsLive, :index
|
||||||
|
live "/support/operations/:queue/:id", SupportOperationLive, :show
|
||||||
end
|
end
|
||||||
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 WhoNeedHelp.AccountsFixtures
|
||||||
import Swoosh.TestAssertions
|
import Swoosh.TestAssertions
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Repo
|
||||||
|
alias WhoNeedHelp.Support.SupportRequest
|
||||||
|
|
||||||
describe "public support and removal intake" do
|
describe "public support and removal intake" do
|
||||||
test "renders each public form", %{conn: conn} do
|
test "renders each public form", %{conn: conn} do
|
||||||
support = html_response(get(conn, ~p"/support"), 200)
|
support = html_response(get(conn, ~p"/support"), 200)
|
||||||
|
|
@ -273,7 +276,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
{:ok, operator_view, _html} =
|
{:ok, operator_view, _html} =
|
||||||
build_conn()
|
build_conn()
|
||||||
|> log_in_user(moderator)
|
|> log_in_user(moderator)
|
||||||
|> live(~p"/support/operations")
|
|> live(~p"/support/operations/support/#{request.id}")
|
||||||
|
|
||||||
operator_view
|
operator_view
|
||||||
|> form("#support-moderation-#{request.id}", %{
|
|> form("#support-moderation-#{request.id}", %{
|
||||||
|
|
@ -344,51 +347,123 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
WhoNeedHelp.Support.access_token(support_request)
|
WhoNeedHelp.Support.access_token(support_request)
|
||||||
)
|
)
|
||||||
|
|
||||||
response =
|
staff_conn = log_in_user(conn, moderator)
|
||||||
conn
|
{:ok, queue_view, response} = live(staff_conn, ~p"/support/operations")
|
||||||
|> log_in_user(moderator)
|
|
||||||
|> get(~p"/support/operations")
|
|
||||||
|> html_response(200)
|
|
||||||
|
|
||||||
assert response =~ "Content removal and TAKE IT DOWN"
|
assert response =~ "Support requests"
|
||||||
assert response =~ "General support and data requests"
|
assert response =~ "Legal and removal"
|
||||||
|
assert has_element?(queue_view, "#support-row-#{support_request.id}")
|
||||||
|
refute response =~ "Operator action"
|
||||||
|
|
||||||
document = LazyHTML.from_document(response)
|
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(
|
assert Enum.count(
|
||||||
LazyHTML.query(
|
LazyHTML.query(
|
||||||
document,
|
removal_document,
|
||||||
"select[id^='removal-'] option[value='open'][selected]"
|
"select[name='moderation[status]'] option[value='open'][selected]"
|
||||||
)
|
)
|
||||||
) == 1
|
) == 1
|
||||||
|
|
||||||
|
assert has_element?(removal_view, "#removal-moderation-#{removal_notice.id}")
|
||||||
|
|
||||||
assert Enum.count(
|
assert Enum.count(
|
||||||
LazyHTML.query(
|
LazyHTML.query(
|
||||||
document,
|
removal_document,
|
||||||
"select[id^='support-'] option[value='open'][selected]"
|
"form[id^='removal-moderation-'] textarea[rows='6'][maxlength='10000']"
|
||||||
)
|
)
|
||||||
) == 1
|
) == 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(
|
assert Enum.count(
|
||||||
LazyHTML.query(
|
LazyHTML.query(
|
||||||
document,
|
support_document,
|
||||||
"form[id^='removal-moderation-'] textarea[rows='4'][maxlength='10000']"
|
"select[name='moderation[status]'] option[value='open'][selected]"
|
||||||
)
|
)
|
||||||
) == 1
|
) == 1
|
||||||
|
|
||||||
|
assert has_element?(support_view, "#support-moderation-#{support_request.id}")
|
||||||
|
|
||||||
assert Enum.count(
|
assert Enum.count(
|
||||||
LazyHTML.query(
|
LazyHTML.query(
|
||||||
document,
|
support_document,
|
||||||
"form[id^='support-moderation-'] textarea[rows='4'][maxlength='5000']"
|
"form[id^='support-moderation-'] textarea[rows='5'][maxlength='5000']"
|
||||||
)
|
)
|
||||||
) == 1
|
) == 1
|
||||||
|
|
||||||
assert response =~ "Operator action"
|
assert support_response =~ "Operator action"
|
||||||
assert response =~ "Reply to requester (optional)"
|
assert support_response =~ "Reply to requester (optional)"
|
||||||
assert response =~ "Audited update"
|
assert removal_response =~ "Audited update"
|
||||||
|
|
||||||
refute response =~
|
refute support_response =~
|
||||||
"This update is recorded, but email is disabled until contact is verified."
|
"This update is recorded, but email is disabled until contact is verified."
|
||||||
end
|
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)
|
||||||
|
|> live(~p"/support/operations")
|
||||||
|
|
||||||
|
assert html =~ "Showing 1–30"
|
||||||
|
|
||||||
|
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(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(LazyHTML.from_document(second_page), "tr[id^='support-row-']")
|
||||||
|
) == 5
|
||||||
|
|
||||||
|
view |> element("button[phx-click='previous-page']") |> render_click()
|
||||||
|
assert render(view) =~ "Showing 1–25"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,14 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
|
||||||
|
|
||||||
{:ok, users_view, html} = live(conn, ~p"/admin/users")
|
{:ok, users_view, html} = live(conn, ~p"/admin/users")
|
||||||
assert html =~ "Users and staff access"
|
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}", %{
|
|> form("#staff-roles-#{target.id}", %{
|
||||||
"staff" => %{"roles" => ["support", "moderator"]}
|
"staff" => %{"roles" => ["support", "moderator"]}
|
||||||
})
|
})
|
||||||
|
|
@ -69,7 +74,7 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
|
||||||
updated = target |> Repo.reload!() |> Accounts.preload_staff_roles()
|
updated = target |> Repo.reload!() |> Accounts.preload_staff_roles()
|
||||||
assert Accounts.loaded_staff_roles(updated) == [:moderator, :support]
|
assert Accounts.loaded_staff_roles(updated) == [:moderator, :support]
|
||||||
|
|
||||||
users_view
|
user_view
|
||||||
|> form("#moderate-user-#{target.id}", %{
|
|> form("#moderate-user-#{target.id}", %{
|
||||||
"moderation" => %{
|
"moderation" => %{
|
||||||
"moderation_status" => "suspended",
|
"moderation_status" => "suspended",
|
||||||
|
|
@ -103,7 +108,7 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
|
||||||
admin = staff_user_fixture([:admin], display_name: "Only administrator")
|
admin = staff_user_fixture([:admin], display_name: "Only administrator")
|
||||||
conn = log_in_user(conn, admin)
|
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 =
|
html =
|
||||||
view
|
view
|
||||||
|
|
@ -113,4 +118,39 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
|
||||||
assert html =~ "last active administrator"
|
assert html =~ "last active administrator"
|
||||||
assert Accounts.staff_roles(admin) == [:admin]
|
assert Accounts.staff_roles(admin) == [:admin]
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user