Redesign staff directories and case workflows

This commit is contained in:
SimpleTest 2026-08-01 17:33:00 +03:00
parent 9279835243
commit 76e1ab7fd8
13 changed files with 1793 additions and 817 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View 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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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