Harden staff operations and verified intake
This commit is contained in:
parent
e558486256
commit
9279835243
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")}",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user