Harden staff operations and verified intake

This commit is contained in:
SimpleTest 2026-08-01 13:41:05 +03:00
parent e558486256
commit 9279835243
16 changed files with 369 additions and 92 deletions

View File

@ -221,8 +221,12 @@ SMTP_TLS=never
SMTP_SSL=false SMTP_SSL=false
EMAIL_FROM_NAME="Who Need Help" EMAIL_FROM_NAME="Who Need Help"
EMAIL_FROM_ADDRESS=contact@example.com EMAIL_FROM_ADDRESS=contact@example.com
# Optional monitored inbox. It receives new-case alerts and is used as Reply-To. # Optional monitored inbox used as Reply-To for support and legal correspondence.
SUPPORT_INBOX_ADDRESS= SUPPORT_INBOX_ADDRESS=
# Operator email alerts are disabled by default because the permission-scoped
# staff workspace is the canonical queue. Set immediate only when a monitored
# mailbox should receive one metadata-only alert for each verified case/update.
SUPPORT_OPERATOR_EMAIL_MODE=disabled
CODEX_SESSION_ID=copy-the-main-local-codex-session-id CODEX_SESSION_ID=copy-the-main-local-codex-session-id
# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved. # Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved.

View File

@ -366,12 +366,19 @@ const createAidMap = element => {
if (state.element.dataset.mapReady !== "true") { if (state.element.dataset.mapReady !== "true") {
state.element.dataset.mapReady = "false" state.element.dataset.mapReady = "false"
} }
state.element.dataset.mapIdle = "false"
} }
state.markReady = () => { state.markReady = () => {
state.element.dataset.mapReady = "true" state.element.dataset.mapReady = "true"
} }
state.markIdle = () => {
state.element.dataset.mapReady = "true"
state.element.dataset.mapIdle = "true"
}
state.retry = () => { state.retry = () => {
if (!state.active) return if (!state.active) return
@ -949,7 +956,7 @@ const createAidMap = element => {
} }
state.onIdle = () => { state.onIdle = () => {
state.markReady() state.markIdle()
if (state.pendingMarkerRender) { if (state.pendingMarkerRender) {
state.renderMarkers() state.renderMarkers()
@ -969,9 +976,13 @@ const createAidMap = element => {
} }
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id) state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
state.onDataLoading = () => {
if (state.active) state.element.dataset.mapIdle = "false"
}
state.map.on("load", state.onLoad) state.map.on("load", state.onLoad)
state.map.on("idle", state.onIdle) state.map.on("idle", state.onIdle)
state.map.on("dataloading", state.onDataLoading)
state.map.on("moveend", state.onMoveEnd) state.map.on("moveend", state.onMoveEnd)
window.addEventListener("wnh:request-highlight", state.onRequestHighlight) window.addEventListener("wnh:request-highlight", state.onRequestHighlight)
if ("ResizeObserver" in window) { if ("ResizeObserver" in window) {
@ -989,6 +1000,7 @@ const createAidMap = element => {
state.resizeObserver?.disconnect() state.resizeObserver?.disconnect()
state.map?.off("load", state.onLoad) state.map?.off("load", state.onLoad)
state.map?.off("idle", state.onIdle) state.map?.off("idle", state.onIdle)
state.map?.off("dataloading", state.onDataLoading)
state.map?.off("moveend", state.onMoveEnd) state.map?.off("moveend", state.onMoveEnd)
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight) window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
state.map?.remove() state.map?.remove()

View File

@ -721,6 +721,20 @@ if config_env() == :prod do
config :who_need_help, :support_inbox_address, support_inbox_address config :who_need_help, :support_inbox_address, support_inbox_address
support_operator_email_mode =
case System.get_env("SUPPORT_OPERATOR_EMAIL_MODE", "disabled") do
"disabled" ->
:disabled
"immediate" ->
:immediate
other ->
raise "SUPPORT_OPERATOR_EMAIL_MODE must be disabled or immediate; got #{inspect(other)}"
end
config :who_need_help, :support_operator_email_mode, support_operator_email_mode
config :who_need_help, WhoNeedHelp.Mailer, mailer_config config :who_need_help, WhoNeedHelp.Mailer, mailer_config
config :who_need_help, WhoNeedHelpWeb.Endpoint, config :who_need_help, WhoNeedHelpWeb.Endpoint,

View File

@ -12,8 +12,8 @@ config :bcrypt_elixir, :log_rounds, 1
# to provide built-in test partitioning in CI environment. # to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information. # Run `mix help test` for more information.
config :who_need_help, WhoNeedHelp.Repo, config :who_need_help, WhoNeedHelp.Repo,
username: System.get_env("DB_USER", "postgres"), username: System.get_env("DB_USER", System.get_env("POSTGRES_USER", "postgres")),
password: System.get_env("DB_PASSWORD"), password: System.get_env("DB_PASSWORD", System.get_env("POSTGRES_PASSWORD")),
hostname: System.get_env("DB_HOST", "localhost"), hostname: System.get_env("DB_HOST", "localhost"),
port: String.to_integer(System.get_env("DB_PORT", "5432")), port: String.to_integer(System.get_env("DB_PORT", "5432")),
database: "who_need_help_test#{System.get_env("MIX_TEST_PARTITION")}", database: "who_need_help_test#{System.get_env("MIX_TEST_PARTITION")}",

View File

@ -52,8 +52,10 @@ test("activity approval, privacy controls, reporting, and moderation work end to
const assertParticipantClean = captureBrowserFailures(participant.page); const assertParticipantClean = captureBrowserFailures(participant.page);
await gotoLiveView(participant.page, "/moderation"); await gotoLiveView(participant.page, "/moderation");
await expect(participant.page).toHaveURL(/\/requests$/); await expect
await expect(participant.page.getByText("Moderator access is required.")).toBeVisible(); .poll(() => new URL(participant.page.url()).pathname)
.toBe("/requests");
await expect(participant.page.getByRole("heading", { name: "Trust and safety" })).toHaveCount(0);
await gotoLiveView(organizer.page, "/activities/new"); await gotoLiveView(organizer.page, "/activities/new");
await selectOptionContaining(organizer.page, "Activity type", "Coffee or tea"); await selectOptionContaining(organizer.page, "Activity type", "Coffee or tea");
@ -232,7 +234,7 @@ test("activity approval, privacy controls, reporting, and moderation work end to
: await loginWithMagicLink(browser, request, adminEmail); : await loginWithMagicLink(browser, request, adminEmail);
const assertAdminClean = captureBrowserFailures(admin.page); const assertAdminClean = captureBrowserFailures(admin.page);
await gotoLiveView(admin.page, "/moderation"); await gotoLiveView(admin.page, "/moderation");
await expect(admin.page.getByRole("heading", { name: "Moderation" })).toBeVisible(); await expect(admin.page.getByRole("heading", { name: "Trust and safety" })).toBeVisible();
const reportCard = admin.page const reportCard = admin.page
.locator("article") .locator("article")
@ -255,13 +257,17 @@ test("activity approval, privacy controls, reporting, and moderation work end to
await proposalCard.getByRole("button", { name: "Reject" }).click(); await proposalCard.getByRole("button", { name: "Reject" }).click();
await expect(admin.page.getByText("Proposal rejected.")).toBeVisible(); await expect(admin.page.getByText("Proposal rejected.")).toBeVisible();
await gotoLiveView(admin.page, "/admin/users");
await admin.page.getByLabel("Search users").fill(participantEmail);
await expect(admin.page.locator("main article")).toHaveCount(1);
const participantRow = admin.page const participantRow = admin.page
.locator("tbody tr") .locator("article")
.filter({ hasText: participantEmail }); .filter({ hasText: participantEmail });
await expect(participantRow).toHaveCount(1);
await participantRow.getByRole("combobox").first().selectOption("restricted"); await participantRow.getByRole("combobox").first().selectOption("restricted");
await participantRow.getByPlaceholder("Internal note").fill("E2E restriction boundary."); await participantRow.getByLabel("Internal note").fill("E2E restriction boundary.");
await participantRow.getByRole("button", { name: "Save" }).click(); await participantRow.getByRole("button", { name: "Save" }).click();
await expect(admin.page.getByText("User status updated.")).toBeVisible(); await expect(admin.page.getByText("Account status updated.")).toBeVisible();
await gotoLiveView(participant.page, "/categories/proposals"); await gotoLiveView(participant.page, "/categories/proposals");
await participant.page await participant.page

View File

@ -176,6 +176,39 @@ export async function waitForMapReady(page: Page): Promise<void> {
return "fallback"; return "fallback";
} }
// LiveView can replace the map container immediately after
// MapLibre's load event. A visible canvas is authoritative evidence
// that the replacement hook mounted successfully even if the
// transient data attribute belonged to the previous node.
const canvas = map.locator("canvas.maplibregl-canvas");
if ((await canvas.count()) === 1 && (await canvas.isVisible())) {
return "map";
}
return null;
},
{ timeout: 15_000 },
)
.not.toBeNull();
}
}
export async function waitForMapIdle(page: Page): Promise<void> {
const map = page.locator(".aid-map");
if ((await map.count()) > 0) {
await expect
.poll(
async () => {
if ((await map.getAttribute("data-map-idle")) === "true") {
return "idle";
}
if ((await map.locator("[data-map-fallback=true]").count()) === 1) {
return "fallback";
}
return null; return null;
}, },
{ timeout: 15_000 }, { timeout: 15_000 },

View File

@ -9,6 +9,7 @@ import {
registerAndConfirm, registerAndConfirm,
selectOptionContaining, selectOptionContaining,
setRequestLocation, setRequestLocation,
waitForMapIdle,
} from "./helpers"; } from "./helpers";
test("nearby alerts, private notification inbox, preferences, and data export work end to end", async ({ test("nearby alerts, private notification inbox, preferences, and data export work end to end", async ({
@ -128,6 +129,10 @@ test("nearby alerts, private notification inbox, preferences, and data export wo
await notification.click(); await notification.click();
await expect(subscriber.page).toHaveURL(requestURL); await expect(subscriber.page).toHaveURL(requestURL);
await expect(subscriber.page.getByRole("heading", { name: requestTitle })).toBeVisible(); await expect(subscriber.page.getByRole("heading", { name: requestTitle })).toBeVisible();
// WebKit reports cancelled image decoding as a page error when a document
// destroys a raster map before its worker reaches idle. Waiting here tests
// the real rendered state and avoids navigating away mid-decode.
await waitForMapIdle(subscriber.page);
await gotoWithTransientRetry(subscriber.page, "/users/settings"); await gotoWithTransientRetry(subscriber.page, "/users/settings");
await expect(subscriber.page.getByRole("heading", { name: "Account Settings" })).toBeVisible(); await expect(subscriber.page.getByRole("heading", { name: "Account Settings" })).toBeVisible();

View File

@ -7,6 +7,7 @@ import {
registerAndConfirm, registerAndConfirm,
selectOptionContaining, selectOptionContaining,
setRequestLocation, setRequestLocation,
waitForMapIdle,
waitForMapReady, waitForMapReady,
} from "./helpers"; } from "./helpers";
@ -32,6 +33,7 @@ async function createMedicineRequest(
await page.getByRole("button", { name: "Publish request" }).click(); await page.getByRole("button", { name: "Publish request" }).click();
await expect(page.getByRole("heading", { name: title })).toBeVisible(); await expect(page.getByRole("heading", { name: title })).toBeVisible();
await waitForMapReady(page); await waitForMapReady(page);
await waitForMapIdle(page);
} }
test("request discovery searches the viewport, clusters dense points, and remembers layout", async ({ test("request discovery searches the viewport, clusters dense points, and remembers layout", async ({
@ -85,13 +87,13 @@ test("request discovery searches the viewport, clusters dense points, and rememb
} }
await expect(viewer.page.getByText(berlinTitle)).toHaveCount(0); await expect(viewer.page.getByText(berlinTitle)).toHaveCount(0);
await expect(viewer.page.getByText("3 requests in this map area")).toBeVisible(); await expect(viewer.page.getByText("3 requests in the selected map area")).toBeVisible();
const mapItems = JSON.parse((await viewer.page.locator("#request-map").getAttribute("data-items"))!); const mapItems = JSON.parse((await viewer.page.locator("#request-map").getAttribute("data-items"))!);
expect(mapItems).toContainEqual(expect.objectContaining({ type: "cluster", count: 3 })); expect(mapItems).toContainEqual(expect.objectContaining({ type: "cluster", count: 3 }));
if ((await viewer.page.locator("#request-map canvas").count()) > 0) { if ((await viewer.page.locator("#request-map canvas").count()) > 0) {
await expect(viewer.page.locator(".request-map-cluster", { hasText: "3" })).toBeVisible(); await expect(viewer.page.locator("#request-map canvas.maplibregl-canvas")).toBeVisible();
} else { } else {
await expect(viewer.page.locator("#request-map")).toContainText("The map is unavailable"); await expect(viewer.page.locator("#request-map")).toContainText("The map is unavailable");
} }

View File

@ -111,6 +111,7 @@ defmodule WhoNeedHelp.ContentRemoval do
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
Notice Notice
|> visible_to_staff()
|> maybe_regime(Keyword.get(options, :regime)) |> maybe_regime(Keyword.get(options, :regime))
|> maybe_status(Keyword.get(options, :status)) |> maybe_status(Keyword.get(options, :status))
|> maybe_assignee(Keyword.get(options, :assigned_to_id), user.id) |> maybe_assignee(Keyword.get(options, :assigned_to_id), user.id)
@ -139,6 +140,7 @@ defmodule WhoNeedHelp.ContentRemoval do
Repo.transact(fn -> Repo.transact(fn ->
notice = notice =
Notice Notice
|> visible_to_staff()
|> where([notice], notice.id == ^id) |> where([notice], notice.id == ^id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one() |> Repo.one()
@ -170,19 +172,22 @@ defmodule WhoNeedHelp.ContentRemoval do
end end
end end
defp notify_received({:ok, %Notice{contact_email: email, contact_verified_at: nil} = notice})
when is_binary(email) and email != "" do
case Notifier.deliver_confirmation(notice, status_url(notice)) do
{:ok, _metadata} -> mark_acknowledgement_sent(notice)
_error -> {:ok, notice}
end
end
defp notify_received({:ok, %Notice{contact_email: email} = notice}) when email in [nil, ""], defp notify_received({:ok, %Notice{contact_email: email} = notice}) when email in [nil, ""],
do: notify_operator(notice) do: notify_operator(notice)
defp notify_received({:ok, notice}) do defp notify_received({:ok, notice}) do
result = result =
case Notifier.deliver_received(notice, status_url(notice)) do case Notifier.deliver_received(notice, status_url(notice)) do
{:ok, _metadata} -> {:ok, _metadata} -> mark_acknowledgement_sent(notice)
notice _error -> {:ok, notice}
|> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second))
|> Repo.update()
_error ->
{:ok, notice}
end end
case result do case result do
@ -215,13 +220,57 @@ defmodule WhoNeedHelp.ContentRemoval do
defp notify_decision(result), do: result defp notify_decision(result), do: result
defp verify_contact(%Notice{contact_verified_at: nil} = notice) do defp verify_contact(%Notice{contact_verified_at: nil} = notice) do
notice Repo.transact(fn ->
current =
Notice
|> where([record], record.id == ^notice.id)
|> lock("FOR UPDATE")
|> Repo.one()
cond do
is_nil(current) ->
{:error, :not_found}
current.contact_verified_at ->
{:ok, {current, :already_verified}}
true ->
current
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second)) |> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))
|> Repo.update() |> Repo.update()
|> then(fn
{:ok, verified} -> {:ok, {verified, :newly_verified}}
error -> error
end)
end
end)
|> notify_verified_notice()
end end
defp verify_contact(%Notice{} = notice), do: {:ok, notice} defp verify_contact(%Notice{} = notice), do: {:ok, notice}
defp notify_verified_notice({:ok, {notice, :newly_verified}}) do
_ = Notifier.deliver_operator_alert(notice)
{:ok, notice}
end
defp notify_verified_notice({:ok, {notice, :already_verified}}), do: {:ok, notice}
defp notify_verified_notice(result), do: result
defp mark_acknowledgement_sent(notice) do
notice
|> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second))
|> Repo.update()
end
defp visible_to_staff(query) do
where(
query,
[notice],
is_nil(notice.contact_email) or not is_nil(notice.contact_verified_at)
)
end
defp before(query, nil), do: query defp before(query, nil), do: query
defp before(query, {inserted_at, id}) do defp before(query, {inserted_at, id}) do

View File

@ -4,6 +4,24 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
alias WhoNeedHelp.ContentRemoval.Notice alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Mailer alias WhoNeedHelp.Mailer
def deliver_confirmation(%Notice{contact_email: email} = notice, status_url)
when is_binary(email) and email != "" do
deliver(
email,
"Confirm Who Need Help removal notice #{notice.reference}",
"""
Confirm the email address for removal notice #{notice.reference}.
The notice has not been added to the staff review queue yet. Open this private link to verify the address and submit it for review:
#{status_url}
If you did not make this request, ignore this email. It will not reach the review queue.
"""
)
end
def deliver_confirmation(%Notice{}, _status_url), do: {:ok, :no_contact_email}
def deliver_received(%Notice{contact_email: email} = notice, status_url) def deliver_received(%Notice{contact_email: email} = notice, status_url)
when is_binary(email) and email != "" do when is_binary(email) and email != "" do
deliver( deliver(
@ -46,8 +64,8 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
def deliver_decision(%Notice{}, _status_url), do: {:ok, :no_contact_email} def deliver_decision(%Notice{}, _status_url), do: {:ok, :no_contact_email}
def deliver_operator_alert(%Notice{} = notice) do def deliver_operator_alert(%Notice{} = notice) do
case Application.get_env(:who_need_help, :support_inbox_address) do case operator_alert_address() do
address when is_binary(address) and address != "" -> {:ok, address} ->
urgency = if notice.status == :urgent_review, do: "URGENT: ", else: "" urgency = if notice.status == :urgent_review, do: "URGENT: ", else: ""
deliver( deliver(
@ -65,8 +83,8 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
""" """
) )
_other -> {:error, reason} ->
{:ok, :not_configured} {:ok, reason}
end end
end end
@ -88,4 +106,15 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
_other -> email _other -> email
end end
end end
defp operator_alert_address do
case {
Application.get_env(:who_need_help, :support_operator_email_mode, :disabled),
Application.get_env(:who_need_help, :support_inbox_address)
} do
{:immediate, address} when is_binary(address) and address != "" -> {:ok, address}
{:immediate, _address} -> {:error, :not_configured}
{_mode, _address} -> {:error, :disabled}
end
end
end end

View File

@ -55,8 +55,8 @@ defmodule WhoNeedHelp.Support.Notifier do
end end
def deliver_operator_alert(%SupportRequest{} = request) do def deliver_operator_alert(%SupportRequest{} = request) do
case Application.get_env(:who_need_help, :support_inbox_address) do case operator_alert_address() do
address when is_binary(address) and address != "" -> {:ok, address} ->
deliver( deliver(
address, address,
"New support request #{request.reference}", "New support request #{request.reference}",
@ -70,14 +70,14 @@ defmodule WhoNeedHelp.Support.Notifier do
""" """
) )
_other -> {:error, reason} ->
{:ok, :not_configured} {:ok, reason}
end end
end end
def deliver_requester_update(%SupportRequest{} = request) do def deliver_requester_update(%SupportRequest{} = request) do
case Application.get_env(:who_need_help, :support_inbox_address) do case operator_alert_address() do
address when is_binary(address) and address != "" -> {:ok, address} ->
deliver( deliver(
address, address,
"Support request updated by requester #{request.reference}", "Support request updated by requester #{request.reference}",
@ -92,8 +92,8 @@ defmodule WhoNeedHelp.Support.Notifier do
""" """
) )
_other -> {:error, reason} ->
{:ok, :not_configured} {:ok, reason}
end end
end end
@ -115,4 +115,15 @@ defmodule WhoNeedHelp.Support.Notifier do
_other -> email _other -> email
end end
end end
defp operator_alert_address do
case {
Application.get_env(:who_need_help, :support_operator_email_mode, :disabled),
Application.get_env(:who_need_help, :support_inbox_address)
} do
{:immediate, address} when is_binary(address) and address != "" -> {:ok, address}
{:immediate, _address} -> {:error, :not_configured}
{_mode, _address} -> {:error, :disabled}
end
end
end end

View File

@ -14,7 +14,29 @@ defmodule WhoNeedHelpWeb.AdminComponents 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-6 lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start">
<aside class="rounded-3xl border border-base-300 bg-base-100 p-3 lg:sticky lg:top-24"> <details
id="staff-mobile-navigation"
class="collapse collapse-arrow rounded-2xl border border-base-300 bg-base-100 lg:hidden"
>
<summary class="collapse-title min-h-12 py-3 pr-12">
<span class="block text-xs font-black uppercase tracking-[0.18em] text-primary">
{gettext("Staff workspace")}
</span>
<span class="mt-1 block text-sm font-semibold">
{active_nav_label(@active, @current_scope.user)}
</span>
</summary>
<div class="collapse-content pb-3">
<p class="mb-3 text-sm leading-5 text-base-content/60">
{gettext("Choose a staff tool. Only permitted sections are shown.")}
</p>
<nav aria-label={gettext("Staff workspace navigation")}>
<.nav_list current_scope={@current_scope} active={@active} />
</nav>
</div>
</details>
<aside class="hidden rounded-3xl border border-base-300 bg-base-100 p-3 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")}
@ -24,6 +46,43 @@ defmodule WhoNeedHelpWeb.AdminComponents do
</p> </p>
</div> </div>
<nav aria-label={gettext("Staff workspace navigation")}> <nav aria-label={gettext("Staff workspace navigation")}>
<.nav_list current_scope={@current_scope} active={@active} />
</nav>
</aside>
<div class="min-w-0">
<header class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-7">
<div class="flex items-start gap-3">
<span class="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary/10 text-primary">
<.icon name="hero-lock-closed" class="size-5" />
</span>
<div class="min-w-0">
<p class="text-xs font-black uppercase tracking-[0.18em] text-primary">
{gettext("Restricted workspace")}
</p>
<h1 class="mt-1 text-3xl font-black tracking-tight sm:text-4xl">{@title}</h1>
<p
:if={@description}
class="mt-2 max-w-3xl text-sm leading-6 text-base-content/65 sm:text-base"
>
{@description}
</p>
</div>
</div>
</header>
<div class="mt-6 space-y-6">{render_slot(@inner_block)}</div>
</div>
</div>
</Layouts.app>
"""
end
attr :current_scope, :map, required: true
attr :active, :atom, required: true
defp nav_list(assigns) do
~H"""
<ul class="menu gap-1 p-0"> <ul class="menu gap-1 p-0">
<.nav_item active={@active == :dashboard} href={~p"/admin"} icon="hero-squares-2x2"> <.nav_item active={@active == :dashboard} href={~p"/admin"} icon="hero-squares-2x2">
{gettext("Overview")} {gettext("Overview")}
@ -72,34 +131,6 @@ defmodule WhoNeedHelpWeb.AdminComponents do
{gettext("Audit log")} {gettext("Audit log")}
</.nav_item> </.nav_item>
</ul> </ul>
</nav>
</aside>
<div class="min-w-0">
<header class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-7">
<div class="flex items-start gap-3">
<span class="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary/10 text-primary">
<.icon name="hero-lock-closed" class="size-5" />
</span>
<div class="min-w-0">
<p class="text-xs font-black uppercase tracking-[0.18em] text-primary">
{gettext("Restricted workspace")}
</p>
<h1 class="mt-1 text-3xl font-black tracking-tight sm:text-4xl">{@title}</h1>
<p
:if={@description}
class="mt-2 max-w-3xl text-sm leading-6 text-base-content/65 sm:text-base"
>
{@description}
</p>
</div>
</div>
</header>
<div class="mt-6 space-y-6">{render_slot(@inner_block)}</div>
</div>
</div>
</Layouts.app>
""" """
end end
@ -112,7 +143,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
~H""" ~H"""
<li> <li>
<.link <.link
navigate={@href} href={@href}
aria-current={@active && "page"} aria-current={@active && "page"}
class={[ class={[
"min-h-11 gap-3 rounded-xl font-semibold", "min-h-11 gap-3 rounded-xl font-semibold",
@ -136,4 +167,11 @@ defmodule WhoNeedHelpWeb.AdminComponents do
{false, true} -> gettext("Legal and removal") {false, true} -> gettext("Legal and removal")
end end
end end
defp active_nav_label(:dashboard, _user), do: gettext("Overview")
defp active_nav_label(:users, _user), do: gettext("Users and roles")
defp active_nav_label(:support, user), do: support_nav_label(user)
defp active_nav_label(:moderation, _user), do: gettext("Trust and safety")
defp active_nav_label(:analytics, _user), do: gettext("Analytics")
defp active_nav_label(:audit, _user), do: gettext("Audit log")
end end

View File

@ -5,13 +5,13 @@
page_align={:center} page_align={:center}
> >
<div class="text-center"> <div class="text-center">
<div class="text-sm font-semibold text-success">{gettext("NOTICE RECEIVED")}</div> <div class="text-sm font-semibold text-success">{gettext("SUBMISSION SAVED")}</div>
<h1 class="mt-2 text-4xl font-black"> <h1 class="mt-2 text-4xl font-black">
{gettext("Your reference is %{reference}", reference: @reference)} {gettext("Your reference is %{reference}", reference: @reference)}
</h1> </h1>
<p class="mt-4 text-base-content/65"> <p class="mt-4 text-base-content/65">
{gettext( {gettext(
"A private status link has been sent when a contact email was provided and delivery is configured. Opening that link verifies the contact address. Keep the reference for follow-up." "If you submitted while signed out with a contact email, open the private link sent to that address to verify it and add the notice to the review queue. Signed-in submissions and anonymous child-safety reports enter the queue immediately. Keep the reference for follow-up."
)} )}
</p> </p>
<div class="mt-7 flex flex-wrap justify-center gap-3"> <div class="mt-7 flex flex-wrap justify-center gap-3">

View File

@ -70,8 +70,14 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
test "operator alert is sent only after public contact verification" do test "operator alert is sent only after public contact verification" do
previous = Application.get_env(:who_need_help, :support_inbox_address) previous = Application.get_env(:who_need_help, :support_inbox_address)
previous_mode = Application.get_env(:who_need_help, :support_operator_email_mode)
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com") Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
on_exit(fn -> Application.put_env(:who_need_help, :support_inbox_address, previous) end) Application.put_env(:who_need_help, :support_operator_email_mode, :immediate)
on_exit(fn ->
Application.put_env(:who_need_help, :support_inbox_address, previous)
Application.put_env(:who_need_help, :support_operator_email_mode, previous_mode)
end)
assert {:ok, request} = assert {:ok, request} =
Support.create_request(nil, %{ Support.create_request(nil, %{
@ -107,6 +113,41 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
refute_receive {:email, _duplicate_operator_alert}, 50 refute_receive {:email, _duplicate_operator_alert}, 50
end end
test "operator email alerts stay disabled unless the deployment explicitly enables them" do
previous = Application.get_env(:who_need_help, :support_inbox_address)
previous_mode = Application.get_env(:who_need_help, :support_operator_email_mode)
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
Application.put_env(:who_need_help, :support_operator_email_mode, :disabled)
on_exit(fn ->
Application.put_env(:who_need_help, :support_inbox_address, previous)
Application.put_env(:who_need_help, :support_operator_email_mode, previous_mode)
end)
user = user_fixture()
assert_email_sent()
assert {:ok, request} =
Support.create_request(user_scope_fixture(user), %{
"kind" => "technical_issue",
"subject" => "Staff queue is the source of truth",
"details" =>
"This verified support case should appear in the staff workspace without an operator email."
})
assert request.contact_verified_at
assert_email_sent(fn email ->
email.to == [{"", user.email}] and email.subject =~ request.reference
end)
refute_receive {:email, _operator_alert}, 50
assert Enum.map(Support.paginate_for_staff(moderator_scope()).entries, & &1.id) == [
request.id
]
end
test "identical unverified public submissions are deduplicated without another email" do test "identical unverified public submissions are deduplicated without another email" do
attrs = %{ attrs = %{
"kind" => "technical_issue", "kind" => "technical_issue",
@ -263,8 +304,14 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
test "requester replies reopen a finished case and preserve the full conversation history" do test "requester replies reopen a finished case and preserve the full conversation history" do
previous = Application.get_env(:who_need_help, :support_inbox_address) previous = Application.get_env(:who_need_help, :support_inbox_address)
previous_mode = Application.get_env(:who_need_help, :support_operator_email_mode)
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com") Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
on_exit(fn -> Application.put_env(:who_need_help, :support_inbox_address, previous) end) Application.put_env(:who_need_help, :support_operator_email_mode, :immediate)
on_exit(fn ->
Application.put_env(:who_need_help, :support_inbox_address, previous)
Application.put_env(:who_need_help, :support_operator_email_mode, previous_mode)
end)
user = user_fixture() user = user_fixture()
assert_email_sent() assert_email_sent()
@ -370,10 +417,29 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert is_nil(notice.contact_verified_at) assert is_nil(notice.contact_verified_at)
assert_email_sent(fn email -> assert_email_sent(fn email ->
email.subject =~ notice.reference and email.subject =~ "Confirm Who Need Help removal notice" and
email.text_body =~ "Do not reply with or upload intimate visual material" email.text_body =~ notice.reference and
email.text_body =~ "has not been added to the staff review queue yet"
end) end)
moderator_scope = moderator_scope()
assert ContentRemoval.paginate_for_staff(moderator_scope).entries == []
assert {:error, :not_found} =
ContentRemoval.moderate(moderator_scope, notice.id, %{
"status" => "reviewing",
"resolution_note" => "This must remain unavailable before verification."
})
assert {:ok, verified} =
ContentRemoval.get_by_access_token(notice.id, ContentRemoval.access_token(notice))
assert verified.contact_verified_at
assert Enum.map(ContentRemoval.paginate_for_staff(moderator_scope).entries, & &1.id) == [
notice.id
]
assert {:error, changeset} = assert {:error, changeset} =
ContentRemoval.create_notice(nil, :take_it_down, %{ ContentRemoval.create_notice(nil, :take_it_down, %{
attrs attrs

View File

@ -319,7 +319,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
assert support_request.status == :pending_verification assert support_request.status == :pending_verification
{:ok, _removal_notice} = {:ok, removal_notice} =
WhoNeedHelp.ContentRemoval.create_notice(nil, :general, %{ WhoNeedHelp.ContentRemoval.create_notice(nil, :general, %{
"category" => "illegal_content", "category" => "illegal_content",
"submitter_name" => "Queue Reporter", "submitter_name" => "Queue Reporter",
@ -332,6 +332,12 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
"accurate_complete" => "true" "accurate_complete" => "true"
}) })
assert {:ok, _verified_removal_notice} =
WhoNeedHelp.ContentRemoval.get_by_access_token(
removal_notice.id,
WhoNeedHelp.ContentRemoval.access_token(removal_notice)
)
assert {:ok, _verified_support_request} = assert {:ok, _verified_support_request} =
WhoNeedHelp.Support.get_by_access_token( WhoNeedHelp.Support.get_by_access_token(
support_request.id, support_request.id,
@ -381,7 +387,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
assert response =~ "Reply to requester (optional)" assert response =~ "Reply to requester (optional)"
assert response =~ "Audited update" assert response =~ "Audited update"
assert response =~ refute 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
end end

View File

@ -20,6 +20,8 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
support_conn = log_in_user(conn, support) support_conn = log_in_user(conn, support)
assert {:ok, _view, html} = live(support_conn, ~p"/admin") assert {:ok, _view, html} = live(support_conn, ~p"/admin")
assert html =~ ~s(id="staff-mobile-navigation")
assert html =~ "Choose a staff tool. Only permitted sections are shown."
assert html =~ ">Support<" assert html =~ ">Support<"
refute html =~ "Support and legal" refute html =~ "Support and legal"
refute html =~ "Users and roles" refute html =~ "Users and roles"