Add permission-scoped staff administration workspace

This commit is contained in:
SimpleTest 2026-08-01 04:54:06 +03:00
parent a540165da4
commit e558486256
41 changed files with 2302 additions and 512 deletions

View File

@ -66,7 +66,10 @@ local Codex CLI authenticated with their ChatGPT subscription.
disables rejected device registrations. disables rejected device registrations.
- Bidirectional discovery blocks, scoped reports, account/request/category - Bidirectional discovery blocks, scoped reports, account/request/category
moderation, abuse-signal review, and audited moderator access to only the moderation, abuse-signal review, and audited moderator access to only the
conversation linked by a report. conversation linked by a report. A unified staff workspace uses combinable
support, moderator, legal, analyst, and administrator roles; administrators
manage users and staff access while the last active administrator is
protected.
- Separate public support and content-removal intake, including moderation - Separate public support and content-removal intake, including moderation
appeals, account deletion/data requests, a URL-only TAKE IT DOWN form, appeals, account deletion/data requests, a URL-only TAKE IT DOWN form,
verified-contact status links, verification-gated support alerts, and audited verified-contact status links, verification-gated support alerts, and audited
@ -590,8 +593,10 @@ Register and confirm the first account, then explicitly bootstrap it:
``` ```
This succeeds only while no administrator exists and writes an audit event. This succeeds only while no administrator exists and writes an audit event.
After bootstrap, an administrator can manage roles in `/moderation`; the last After bootstrap, an administrator can manage multiple staff roles in
administrator cannot demote themselves. For kind, append `kind`: `/admin/users`; the last active administrator cannot remove their own admin
access or be restricted. The complete role matrix and operator workflow are in
[`docs/staff-operations.md`](docs/staff-operations.md). For kind, append `kind`:
```bash ```bash
./scripts/bootstrap-admin.sh you@example.com --confirm kind ./scripts/bootstrap-admin.sh you@example.com --confirm kind

81
docs/staff-operations.md Normal file
View File

@ -0,0 +1,81 @@
# Staff workspace and access control
Status: implemented. The staff workspace starts at `/admin` and uses
database-checked permissions on every protected route and context operation.
Navigation visibility is only a usability aid; it is not the authorization
boundary.
## Role model
A confirmed account can hold any combination of these fixed roles:
| Role | Access |
| --- | --- |
| `support` | Verified support queue, assignment, conversation, status, and response |
| `moderator` | Reports, scoped evidence, abuse signals, request/activity moderation, user restriction, and category proposals |
| `legal` | Content-removal and TAKE IT DOWN queue, assignment, status, and decision |
| `analyst` | Aggregate privacy-preserving product analytics |
| `admin` | Every staff permission, role administration, user administration, and audit log |
Permissions are the union of all assigned roles. For example, one account may
hold both `support` and `moderator`. The ordinary user state is represented by
having no staff-role assignments; `user` is not a staff role.
The matrix is deliberately fixed in
`WhoNeedHelp.Accounts.StaffPermissions`. The UI cannot create arbitrary roles
or attach one-off permissions, which keeps review and auditing unambiguous.
## Workspaces
- `/admin` — permission-scoped operational totals and the current account's
staff roles.
- `/admin/users` — account search and filtering, restriction/suspension, and
multi-role assignment. Moderators can act on ordinary accounts;
administrators can also act on staff accounts and change roles.
- `/support/operations` — support and legal queues. Each section is rendered
only when the operator has its permission. Cases can be filtered and assigned
only to an active account that can manage the matching queue.
- `/moderation` — reports, scoped evidence, abuse signals, hidden content, and
category proposals.
- `/analytics` — aggregate metrics for analysts and administrators.
- `/admin/audit` — administrator-only, cursor-paginated audit records with
actor, action, target, timestamp, and stored metadata.
## Administrative safeguards
- Role changes require an active administrator and a session authenticated in
the preceding ten minutes. A stale session receives a forbidden result and
must authenticate again.
- The final active administrator cannot lose the `admin` role and cannot be
restricted or suspended.
- A staff account can be restricted or suspended only by an administrator.
A moderator cannot change another staff account.
- An operator cannot restrict or suspend their own account.
- Suspending an account deletes its login tokens, disconnects active LiveView
sessions, and stops/deletes current live-location state.
- Changing staff roles disconnects the affected account's active sessions so
the next session loads the new permission set.
- Assignment is validated on the server. A support case cannot be assigned to
a legal-only or ordinary account, and a legal case cannot be assigned to a
support-only or ordinary account.
- Sensitive changes record audit events. Audit rows are not editable from the
staff UI.
## First administrator
After the first account has registered and confirmed its email, bootstrap the
initial administrator exactly once:
```bash
./scripts/bootstrap-admin.sh you@example.com --confirm
```
The command refuses to run after an administrator exists and records an audit
event. All subsequent role changes are made from `/admin/users`.
## Operational checks
Before granting access, confirm that the person needs the smallest applicable
role or role combination. After a change, verify the corresponding audit event
and ask the operator to start a new session. Regularly review unassigned support
and legal queues, suspended staff accounts, and the audit log.

View File

@ -17,9 +17,13 @@ Who Need Help deliberately separates three mechanisms:
infringe a right. TAKE IT DOWN notices have a dedicated public form and a infringe a right. TAKE IT DOWN notices have a dedicated public form and a
separate regime value in the removal queue. separate regime value in the removal queue.
The operator workspace is `/support/operations`. It is protected by the same The operator workspace is `/support/operations`. Support and legal access are
database-checked moderator or administrator authorization as the existing separate database-checked permissions that can be combined on one account;
moderation workspace. Every creation and decision records an audit event. administrators have both. Each queue supports search, status/type filtering,
assignee filtering, cursor pagination, and assignment only to an active member
of the corresponding team. Every creation and operator decision records an
audit event. The complete role matrix is documented in
`docs/staff-operations.md`.
## Public routes ## Public routes

View File

@ -122,8 +122,10 @@ does not reveal its private chat. Only a report targeting a specific Activity
message permits the moderator to load that Activity's group conversation, and message permits the moderator to load that Activity's group conversation, and
the evidence access audit records the Activity and message count. the evidence access audit records the Activity and message count.
Report, request, signal, category, role, and account moderation are role Report, request, signal, category, role, and account moderation are protected by
protected. The local Codex the multi-role permission matrix described in `docs/staff-operations.md`.
Support, moderation, legal, and analytics access can be combined on one
account; administrators receive every permission. The local Codex
batch receives proposal text and aggregate vote counts only. It receives no batch receives proposal text and aggregate vote counts only. It receives no
private messages, email addresses, OAuth tokens, exact coordinates, or raw private messages, email addresses, OAuth tokens, exact coordinates, or raw
tracking routes. tracking routes.
@ -142,7 +144,8 @@ Reports have `open`, `reviewing`, `resolved`, and `dismissed` states. A severe
report can hide a request and temporarily restrict an account pending review. report can hide a request and temporarily restrict an account pending review.
Suspension invalidates that account's login sessions. The first administrator Suspension invalidates that account's login sessions. The first administrator
requires an explicit one-time operational bootstrap, and later role changes are requires an explicit one-time operational bootstrap, and later role changes are
audited; the last administrator cannot demote themselves. audited; the last active administrator cannot remove their own admin access or
be restricted.
The operator must publish jurisdiction-specific emergency contacts, privacy The operator must publish jurisdiction-specific emergency contacts, privacy
notice, prohibited-items policy, and data-retention policy before public launch. notice, prohibited-items policy, and data-retention policy before public launch.

View File

@ -4,7 +4,7 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
import Ecto.Query import Ecto.Query
alias Oban.Job alias Oban.Job
alias WhoNeedHelp.Accounts.{SocialIdentity, User} alias WhoNeedHelp.Accounts.{SocialIdentity, StaffRoleAssignment, User}
alias WhoNeedHelp.Activities.{Activity, Participant} alias WhoNeedHelp.Activities.{Activity, Participant}
alias WhoNeedHelp.Activities.Message, as: ActivityMessage alias WhoNeedHelp.Activities.Message, as: ActivityMessage
alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote} alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote}
@ -94,17 +94,20 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
{:ok, users} = {:ok, users} =
Repo.transaction(fn -> Repo.transaction(fn ->
Map.new(@precreated_roles, fn role -> Map.new(@precreated_roles, fn role ->
user_role = if role == "admin", do: :admin, else: :user
user = user =
insert_user!( insert_user!(
context.emails[role], context.emails[role],
display_name(role), display_name(role),
password_hash, password_hash,
user_role,
now now
) )
if role == "admin" do
%StaffRoleAssignment{}
|> StaffRoleAssignment.changeset(%{user_id: user.id, role: :admin})
|> Repo.insert!()
end
{role, %{"id" => user.id, "email" => user.email}} {role, %{"id" => user.id, "email" => user.email}}
end) end)
end) end)
@ -500,7 +503,7 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
defp delete_count(query), do: query |> Repo.delete_all() |> elem(0) defp delete_count(query), do: query |> Repo.delete_all() |> elem(0)
defp insert_user!(email, display_name, password_hash, role, now) do defp insert_user!(email, display_name, password_hash, now) do
%User{} %User{}
|> User.registration_changeset(%{ |> User.registration_changeset(%{
"email" => email, "email" => email,
@ -510,7 +513,6 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
}) })
|> Ecto.Changeset.put_change(:hashed_password, password_hash) |> Ecto.Changeset.put_change(:hashed_password, password_hash)
|> Ecto.Changeset.put_change(:confirmed_at, now) |> Ecto.Changeset.put_change(:confirmed_at, now)
|> Ecto.Changeset.put_change(:role, role)
|> Repo.insert!() |> Repo.insert!()
end end

View File

@ -11,6 +11,8 @@ defmodule WhoNeedHelp.Accounts do
AuthIdentity, AuthIdentity,
Scope, Scope,
SocialIdentity, SocialIdentity,
StaffPermissions,
StaffRoleAssignment,
User, User,
UserNotifier, UserNotifier,
UserToken UserToken
@ -154,46 +156,85 @@ defmodule WhoNeedHelp.Accounts do
) )
end end
def moderator?(%User{role: role}), do: role in [:moderator, :admin] def staff_roles(%User{id: id}) do
def moderator?(_user), do: false StaffRoleAssignment
|> where([assignment], assignment.user_id == ^id)
|> order_by([assignment], asc: assignment.role)
|> select([assignment], assignment.role)
|> Repo.all()
end
def moderator_authorized?(%User{id: id, role: role}) def loaded_staff_roles(%User{staff_role_assignments: %Ecto.Association.NotLoaded{}}), do: []
when role in [:moderator, :admin],
do: def loaded_staff_roles(%User{staff_role_assignments: assignments}) when is_list(assignments),
do: Enum.map(assignments, & &1.role)
def loaded_staff_roles(_user), do: []
def permission?(%User{moderation_status: :active} = user, permission) do
StaffPermissions.allowed?(loaded_staff_roles(user), permission)
end
def permission?(_user, _permission), do: false
def authorized?(%User{id: id}, permission) do
allowed_roles = StaffPermissions.roles_for_permission(permission)
allowed_roles != [] and
Repo.exists?( Repo.exists?(
from user in User, from assignment in StaffRoleAssignment,
join: user in User,
on: user.id == assignment.user_id,
where: where:
user.id == ^id and user.role in [:moderator, :admin] and assignment.user_id == ^id and assignment.role in ^allowed_roles and
user.moderation_status == :active user.moderation_status == :active
) )
end
def moderator_authorized?(_user), do: false def authorized?(_user, _permission), do: false
def admin?(%User{role: :admin}), do: true def authorized_user_id?(user_id, permission) do
def admin?(_user), do: false with {:ok, user_id} <- cast_id(user_id) do
allowed_roles = StaffPermissions.roles_for_permission(permission)
def admin_authorized?(%User{id: id, role: :admin}), allowed_roles != [] and
do:
Repo.exists?( Repo.exists?(
from user in User, from assignment in StaffRoleAssignment,
where: user.id == ^id and user.role == :admin and user.moderation_status == :active join: user in User,
on: user.id == assignment.user_id,
where:
assignment.user_id == ^user_id and assignment.role in ^allowed_roles and
user.moderation_status == :active
) )
else
_ -> false
end
end
def admin_authorized?(_user), do: false def staff?(user), do: permission?(user, :staff_access)
def staff_authorized?(user), do: authorized?(user, :staff_access)
def moderator?(user), do: permission?(user, :moderation_view)
def moderator_authorized?(user), do: authorized?(user, :moderation_view)
def admin?(user), do: :admin in loaded_staff_roles(user)
def admin_authorized?(user), do: authorized?(user, :staff_manage)
def list_users_for_moderation(%Scope{user: user}) do def list_users_for_moderation(%Scope{user: user}) do
paginate_users_for_moderation(%Scope{user: user}).entries paginate_users_for_moderation(%Scope{user: user}).entries
end end
def paginate_users_for_moderation(%Scope{user: user}, options \\ []) do def paginate_users_for_moderation(%Scope{user: user}, options \\ []) do
if moderator_authorized?(user) do if authorized?(user, :users_view) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
User User
|> maybe_user_status(Keyword.get(options, :status))
|> maybe_user_role(Keyword.get(options, :role))
|> maybe_user_search(Keyword.get(options, :search))
|> before_moderation_user(cursor) |> before_moderation_user(cursor)
|> order_by([user], desc: user.inserted_at, desc: user.id) |> order_by([user], desc: user.inserted_at, desc: user.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload(:staff_role_assignments)
|> Repo.all() |> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id}) |> Pagination.page(limit, &{&1.inserted_at, &1.id})
else else
@ -203,7 +244,7 @@ defmodule WhoNeedHelp.Accounts do
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 <- moderator_authorized?(moderator) do true <- authorized?(moderator, :users_moderate) do
Repo.transact(fn -> Repo.transact(fn ->
active_admins = lock_active_admins() active_admins = lock_active_admins()
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one() user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
@ -212,14 +253,14 @@ defmodule WhoNeedHelp.Accounts do
requested_status = attrs["moderation_status"] || attrs[:moderation_status] requested_status = attrs["moderation_status"] || attrs[:moderation_status]
cond do cond do
user.role in [:moderator, :admin] and not admin_authorized?(moderator) -> user_has_staff_roles?(user.id) and not admin_authorized?(moderator) ->
{:error, :forbidden} {:error, :forbidden}
user.id == moderator.id and user.id == moderator.id and
requested_status in [:restricted, :suspended, "restricted", "suspended"] -> requested_status in [:restricted, :suspended, "restricted", "suspended"] ->
{:error, :cannot_restrict_self} {:error, :cannot_restrict_self}
user.role == :admin and user.moderation_status == :active and user_has_role?(user.id, :admin) and user.moderation_status == :active and
requested_status not in [:active, "active"] and length(active_admins) == 1 -> requested_status not in [:active, "active"] and length(active_admins) == 1 ->
{:error, :last_admin} {:error, :last_admin}
@ -252,21 +293,54 @@ defmodule WhoNeedHelp.Accounts do
end end
end end
def change_user_role(%User{} = admin, user_id, attrs) do def set_staff_roles(%User{} = admin, user_id, roles) when is_list(roles) do
with {:ok, user_id} <- cast_id(user_id), with {:ok, user_id} <- cast_id(user_id),
true <- admin_authorized?(admin) do true <- admin_authorized?(admin),
true <- sudo_mode?(admin, -10),
{:ok, roles} <- normalize_staff_roles(roles) do
Repo.transact(fn -> Repo.transact(fn ->
active_admins = lock_active_admins() active_admins = lock_active_admins()
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one() user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
if user do if user do
requested_role = attrs["role"] || attrs[:role] current_roles = staff_roles(user)
if user.role == :admin and user.moderation_status == :active and if :admin in current_roles and user.moderation_status == :active and
requested_role not in [:admin, "admin"] and length(active_admins) == 1 do :admin not in roles and length(active_admins) == 1 do
{:error, :last_admin} {:error, :last_admin}
else else
user |> User.role_changeset(attrs) |> Repo.update() if roles == [] do
Repo.delete_all(
from assignment in StaffRoleAssignment,
where: assignment.user_id == ^user.id
)
else
StaffRoleAssignment
|> where(
[assignment],
assignment.user_id == ^user.id and assignment.role not in ^roles
)
|> Repo.delete_all()
end
roles
|> Enum.reject(&(&1 in current_roles))
|> Enum.each(fn role ->
%StaffRoleAssignment{}
|> StaffRoleAssignment.changeset(%{
user_id: user.id,
role: role,
assigned_by_id: admin.id
})
|> Repo.insert!()
end)
session_tokens =
UserToken
|> where([token], token.user_id == ^user.id and token.context == "session")
|> Repo.all()
{:ok, %{user: preload_staff_roles(user), session_tokens: session_tokens}}
end end
else else
{:error, :not_found} {:error, :not_found}
@ -275,17 +349,76 @@ defmodule WhoNeedHelp.Accounts do
else else
false -> {:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error {:error, :not_found} = error -> error
{:error, :invalid_roles} = error -> error
end end
end end
def set_staff_roles(_admin, _user_id, _roles), do: {:error, :invalid_roles}
defp lock_active_admins do defp lock_active_admins do
User active_user_ids =
|> where([user], user.role == :admin and user.moderation_status == :active) from user in User,
|> order_by([user], asc: user.id) where: user.moderation_status == :active,
select: user.id
StaffRoleAssignment
|> where(
[assignment],
assignment.role == :admin and assignment.user_id in subquery(active_user_ids)
)
|> order_by([assignment], asc: assignment.user_id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.all() |> Repo.all()
end end
def list_staff_for_permission(permission) do
allowed_roles = StaffPermissions.roles_for_permission(permission)
User
|> join(:inner, [user], assignment in StaffRoleAssignment,
on: assignment.user_id == user.id and assignment.role in ^allowed_roles
)
|> where([user], user.moderation_status == :active)
|> order_by([user], asc: user.display_name, asc: user.email)
|> distinct(true)
|> preload(:staff_role_assignments)
|> Repo.all()
end
def preload_staff_roles(%User{} = user),
do: Repo.preload(user, :staff_role_assignments, force: true)
defp user_has_staff_roles?(user_id) do
Repo.exists?(from assignment in StaffRoleAssignment, where: assignment.user_id == ^user_id)
end
defp user_has_role?(user_id, role) do
Repo.exists?(
from assignment in StaffRoleAssignment,
where: assignment.user_id == ^user_id and assignment.role == ^role
)
end
defp normalize_staff_roles(roles) do
roles =
roles
|> Enum.map(fn
role when is_atom(role) ->
role
role when is_binary(role) ->
Enum.find(StaffRoleAssignment.roles(), &(Atom.to_string(&1) == role))
_other ->
nil
end)
|> Enum.uniq()
if Enum.all?(roles, &StaffPermissions.valid_role?/1),
do: {:ok, Enum.sort(roles)},
else: {:error, :invalid_roles}
end
## User registration ## User registration
@doc """ @doc """
@ -809,7 +942,11 @@ defmodule WhoNeedHelp.Accounts do
""" """
def get_user_by_session_token(token) do def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token) {:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
case Repo.one(query) do
{%User{} = user, inserted_at} -> {preload_staff_roles(user), inserted_at}
nil -> nil
end
end end
@doc """ @doc """
@ -967,6 +1104,49 @@ defmodule WhoNeedHelp.Accounts do
) )
end end
defp maybe_user_status(query, value) when value in [nil, ""], do: query
defp maybe_user_status(query, value) do
case Ecto.Enum.cast_value(User, :moderation_status, value) do
{:ok, status} -> where(query, [user], user.moderation_status == ^status)
:error -> where(query, [user], false)
end
end
defp maybe_user_role(query, value) when value in [nil, ""], do: query
defp maybe_user_role(query, "user") do
from user in query,
left_join: assignment in StaffRoleAssignment,
on: assignment.user_id == user.id,
where: is_nil(assignment.id)
end
defp maybe_user_role(query, value) do
case Enum.find(StaffPermissions.roles(), &(Atom.to_string(&1) == to_string(value))) do
nil ->
where(query, [user], false)
role ->
from user in query,
join: assignment in StaffRoleAssignment,
on: assignment.user_id == user.id and assignment.role == ^role
end
end
defp maybe_user_search(query, value) when value in [nil, ""], do: query
defp maybe_user_search(query, value) do
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
pattern = "%#{term}%"
where(
query,
[user],
ilike(user.email, ^pattern) or ilike(user.display_name, ^pattern)
)
end
defp cast_id(value) do defp cast_id(value) do
case Ecto.UUID.cast(value) do case Ecto.UUID.cast(value) do
{:ok, id} -> {:ok, id} {:ok, id} -> {:ok, id}

View File

@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Accounts.DataExport do
import Ecto.Query import Ecto.Query
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.{AuthIdentity, Scope, SocialIdentity, User} alias WhoNeedHelp.Accounts.{AuthIdentity, Scope, SocialIdentity, User}
alias WhoNeedHelp.Activities.{Activity, Message, Participant} alias WhoNeedHelp.Activities.{Activity, Message, Participant}
alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote} alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote}
@ -31,7 +32,8 @@ defmodule WhoNeedHelp.Accounts.DataExport do
"version" => @version, "version" => @version,
"exported_at" => exported_at, "exported_at" => exported_at,
"account" => "account" =>
record(user, [ user
|> record([
:id, :id,
:email, :email,
:display_name, :display_name,
@ -39,14 +41,14 @@ defmodule WhoNeedHelp.Accounts.DataExport do
:locale, :locale,
:location_visibility, :location_visibility,
:direct_message_policy, :direct_message_policy,
:role,
:moderation_status, :moderation_status,
:tip_url, :tip_url,
:confirmed_at, :confirmed_at,
:accepted_terms_at, :accepted_terms_at,
:inserted_at, :inserted_at,
:updated_at :updated_at
]), ])
|> Map.put(:staff_roles, Accounts.staff_roles(user)),
"authentication_identities" => "authentication_identities" =>
owned(AuthIdentity, :user_id, user_id, [ owned(AuthIdentity, :user_id, user_id, [
:id, :id,

View File

@ -10,6 +10,7 @@ defmodule WhoNeedHelp.Accounts.DataLifecycle do
import Ecto.Query import Ecto.Query
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.{Scope, User} alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.Activities.{Activity, Participant} alias WhoNeedHelp.Activities.{Activity, Participant}
alias WhoNeedHelp.Help.{Assignment, HelpRequest} alias WhoNeedHelp.Help.{Assignment, HelpRequest}
@ -45,7 +46,7 @@ defmodule WhoNeedHelp.Accounts.DataLifecycle do
case_id: request.id, case_id: request.id,
reference: request.reference, reference: request.reference,
account_id: user.id, account_id: user.id,
account_role: user.role, account_staff_roles: Accounts.staff_roles(user),
contact_verified: true, contact_verified: true,
assessed_by: moderator.id, assessed_by: moderator.id,
assessed_at: DateTime.utc_now(:second), assessed_at: DateTime.utc_now(:second),

View File

@ -0,0 +1,70 @@
defmodule WhoNeedHelp.Accounts.StaffPermissions do
@moduledoc """
Central, deliberately fixed staff role and permission policy.
Roles can be combined. Administrators receive every permission and are the
only role allowed to change staff access or inspect the audit trail.
"""
@roles [:support, :moderator, :legal, :analyst, :admin]
@permissions [
:staff_access,
:dashboard_view,
:support_view,
:support_manage,
:moderation_view,
:moderation_manage,
:moderation_evidence_view,
:users_view,
:users_moderate,
:categories_manage,
:legal_view,
:legal_manage,
:analytics_view,
:staff_manage,
:audit_view
]
@role_permissions %{
support: [:staff_access, :dashboard_view, :support_view, :support_manage],
moderator: [
:staff_access,
:dashboard_view,
:moderation_view,
:moderation_manage,
:moderation_evidence_view,
:users_view,
:users_moderate,
:categories_manage
],
legal: [:staff_access, :dashboard_view, :legal_view, :legal_manage],
analyst: [:staff_access, :dashboard_view, :analytics_view],
admin: @permissions
}
def roles, do: @roles
def permissions, do: @permissions
def valid_role?(role), do: role in @roles
def permissions_for_roles(roles) when is_list(roles) do
roles
|> Enum.flat_map(&Map.get(@role_permissions, &1, []))
|> MapSet.new()
end
def allowed?(roles, permission) when permission in @permissions do
roles
|> permissions_for_roles()
|> MapSet.member?(permission)
end
def allowed?(_roles, _permission), do: false
def roles_for_permission(permission) when permission in @permissions do
@roles
|> Enum.filter(fn role -> permission in Map.fetch!(@role_permissions, role) end)
end
def roles_for_permission(_permission), do: []
end

View File

@ -0,0 +1,28 @@
defmodule WhoNeedHelp.Accounts.StaffRoleAssignment do
use Ecto.Schema
import Ecto.Changeset
@roles [:support, :moderator, :legal, :analyst, :admin]
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "staff_role_assignments" do
field :role, Ecto.Enum, values: @roles
belongs_to :user, WhoNeedHelp.Accounts.User
belongs_to :assigned_by, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime)
end
def roles, do: @roles
def changeset(assignment, attrs) do
assignment
|> cast(attrs, [:user_id, :role, :assigned_by_id])
|> validate_required([:user_id, :role])
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:assigned_by_id)
|> unique_constraint([:user_id, :role])
end
end

View File

@ -24,8 +24,6 @@ defmodule WhoNeedHelp.Accounts.User do
values: [:everyone, :verified_accounts, :completed_help_users, :nobody], values: [:everyone, :verified_accounts, :completed_help_users, :nobody],
default: :verified_accounts default: :verified_accounts
field :role, Ecto.Enum, values: [:user, :moderator, :admin], default: :user
field :moderation_status, Ecto.Enum, field :moderation_status, Ecto.Enum,
values: [:active, :restricted, :suspended], values: [:active, :restricted, :suspended],
default: :active default: :active
@ -40,6 +38,9 @@ defmodule WhoNeedHelp.Accounts.User do
has_many :nearby_subscriptions, WhoNeedHelp.Notifications.NearbySubscription has_many :nearby_subscriptions, WhoNeedHelp.Notifications.NearbySubscription
has_many :push_devices, WhoNeedHelp.Notifications.PushDevice has_many :push_devices, WhoNeedHelp.Notifications.PushDevice
has_many :staff_role_assignments, WhoNeedHelp.Accounts.StaffRoleAssignment,
preload_order: [asc: :role]
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -115,12 +116,6 @@ defmodule WhoNeedHelp.Accounts.User do
end end
end end
def role_changeset(user, attrs) do
user
|> cast(attrs, [:role])
|> validate_required([:role])
end
defp validate_url(changeset, field) do defp validate_url(changeset, field) do
validate_change(changeset, field, fn ^field, value -> validate_change(changeset, field, fn ^field, value ->
case URI.parse(value) do case URI.parse(value) do

View File

@ -0,0 +1,63 @@
defmodule WhoNeedHelp.Admin do
@moduledoc "Read-only operational summaries for the staff workspace."
import Ecto.Query
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.{Scope, StaffRoleAssignment, User}
alias WhoNeedHelp.Catalog.CategoryProposal
alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Support.SupportRequest
alias WhoNeedHelp.Trust.{AbuseSignal, Report}
def dashboard(%Scope{user: user}) do
if Accounts.authorized?(user, :dashboard_view) do
%{}
|> maybe_put(user, :support_view, :support_open, fn ->
count(SupportRequest, [status: [:open, :reviewing, :waiting_for_requester]], true)
end)
|> maybe_put(user, :legal_view, :legal_open, fn ->
count(Notice, status: [:open, :urgent_review, :reviewing, :needs_information])
end)
|> maybe_put(user, :moderation_view, :reports_open, fn ->
count(Report, status: [:open, :reviewing])
end)
|> maybe_put(user, :moderation_view, :signals_open, fn ->
count(AbuseSignal, status: [:open])
end)
|> maybe_put(user, :categories_manage, :category_proposals_open, fn ->
count(CategoryProposal, status: [:open])
end)
|> maybe_put(user, :users_view, :users_total, fn -> Repo.aggregate(User, :count) end)
|> maybe_put(user, :staff_manage, :staff_total, fn ->
Repo.one(
from assignment in StaffRoleAssignment,
select: count(assignment.user_id, :distinct)
)
end)
else
%{}
end
end
defp maybe_put(summary, user, permission, key, value_fun) do
if Accounts.authorized?(user, permission),
do: Map.put(summary, key, value_fun.()),
else: summary
end
defp count(schema, filters, verified_only \\ false) do
query =
Enum.reduce(filters, schema, fn
{:status, statuses}, query -> where(query, [record], record.status in ^statuses)
end)
query =
if verified_only,
do: where(query, [record], not is_nil(record.contact_verified_at)),
else: query
Repo.aggregate(query, :count)
end
end

View File

@ -185,7 +185,7 @@ defmodule WhoNeedHelp.Catalog do
end end
def paginate_proposals_for_moderation(%Scope{user: user}, options \\ []) do def paginate_proposals_for_moderation(%Scope{user: user}, options \\ []) do
if Accounts.moderator_authorized?(user) do if Accounts.authorized?(user, :categories_manage) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
@ -204,7 +204,7 @@ defmodule WhoNeedHelp.Catalog do
def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do
with {:ok, proposal_id} <- cast_id(proposal_id), with {:ok, proposal_id} <- cast_id(proposal_id),
true <- Accounts.moderator_authorized?(moderator) do true <- Accounts.authorized?(moderator, :categories_manage) do
Repo.transact(fn -> Repo.transact(fn ->
proposal = locked_proposal(proposal_id) proposal = locked_proposal(proposal_id)
@ -267,7 +267,7 @@ defmodule WhoNeedHelp.Catalog do
defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do
with {:ok, proposal_id} <- cast_id(proposal_id), with {:ok, proposal_id} <- cast_id(proposal_id),
true <- Accounts.moderator_authorized?(moderator) do true <- Accounts.authorized?(moderator, :categories_manage) do
Repo.transact(fn -> Repo.transact(fn ->
proposal = locked_proposal(proposal_id) proposal = locked_proposal(proposal_id)

View File

@ -106,16 +106,19 @@ defmodule WhoNeedHelp.ContentRemoval do
end end
def paginate_for_staff(%Scope{user: user}, options \\ []) do def paginate_for_staff(%Scope{user: user}, options \\ []) do
if Accounts.moderator_authorized?(user) do if Accounts.authorized?(user, :legal_view) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
Notice Notice
|> maybe_regime(Keyword.get(options, :regime)) |> maybe_regime(Keyword.get(options, :regime))
|> maybe_status(Keyword.get(options, :status))
|> maybe_assignee(Keyword.get(options, :assigned_to_id), user.id)
|> maybe_search(Keyword.get(options, :search))
|> before(cursor) |> before(cursor)
|> order_by([notice], desc: notice.inserted_at, desc: notice.id) |> order_by([notice], desc: notice.inserted_at, desc: notice.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload([:requester, :reviewed_by]) |> preload([:requester, :reviewed_by, :assigned_to])
|> Repo.all() |> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id}) |> Pagination.page(limit, &{&1.inserted_at, &1.id})
else else
@ -125,11 +128,10 @@ defmodule WhoNeedHelp.ContentRemoval do
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.moderator_authorized?(moderator) do true <- Accounts.authorized?(moderator, :legal_manage) do
with {:ok, attrs} <- normalize_assignment(normalize_keys(attrs), :legal_manage) do
attrs = attrs =
attrs Map.merge(attrs, %{
|> normalize_keys()
|> Map.merge(%{
"reviewed_at" => DateTime.utc_now(:second), "reviewed_at" => DateTime.utc_now(:second),
"reviewed_by_id" => moderator.id "reviewed_by_id" => moderator.id
}) })
@ -150,16 +152,18 @@ defmodule WhoNeedHelp.ContentRemoval do
"content_removal_notice", "content_removal_notice",
notice.id, notice.id,
%{ %{
"status" => to_string(notice.status) "status" => to_string(notice.status),
"assigned_to_id" => notice.assigned_to_id
} }
) do ) do
{:ok, notice} {:ok, Repo.preload(notice, :assigned_to, force: true)}
end end
else else
{:error, :not_found} {:error, :not_found}
end end
end) end)
|> notify_decision() |> notify_decision()
end
else else
false -> {:error, :forbidden} false -> {:error, :forbidden}
_ -> {:error, :not_found} _ -> {:error, :not_found}
@ -230,8 +234,54 @@ defmodule WhoNeedHelp.ContentRemoval do
end end
defp maybe_regime(query, nil), do: query defp maybe_regime(query, nil), do: query
defp maybe_regime(query, ""), do: query
defp maybe_regime(query, regime), do: where(query, [notice], notice.regime == ^regime) defp maybe_regime(query, regime), do: where(query, [notice], notice.regime == ^regime)
defp maybe_status(query, nil), do: query
defp maybe_status(query, ""), do: query
defp maybe_status(query, status), do: where(query, [notice], notice.status == ^status)
defp maybe_assignee(query, nil, _current_user_id), do: query
defp maybe_assignee(query, "", _current_user_id), do: query
defp maybe_assignee(query, "unassigned", _current_user_id),
do: where(query, [n], is_nil(n.assigned_to_id))
defp maybe_assignee(query, "mine", current_user_id),
do: where(query, [n], n.assigned_to_id == ^current_user_id)
defp maybe_assignee(query, assignee_id, _current_user_id),
do: where(query, [n], n.assigned_to_id == ^assignee_id)
defp maybe_search(query, value) when value in [nil, ""], do: query
defp maybe_search(query, value) do
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
pattern = "%#{term}%"
where(
query,
[notice],
ilike(notice.reference, ^pattern) or ilike(notice.contact_email, ^pattern) or
ilike(notice.submitter_name, ^pattern)
)
end
defp normalize_assignment(attrs, permission) do
case Map.fetch(attrs, "assigned_to_id") do
:error ->
{:ok, attrs}
{:ok, value} when value in [nil, ""] ->
{:ok, Map.put(attrs, "assigned_to_id", nil)}
{:ok, user_id} ->
if Accounts.authorized_user_id?(user_id, permission),
do: {:ok, attrs},
else: {:error, :invalid_assignee}
end
end
defp maybe_use_user_email(attrs, %User{email: email}) do defp maybe_use_user_email(attrs, %User{email: email}) do
Map.put(attrs, "contact_email", email) Map.put(attrs, "contact_email", email)
end end

View File

@ -55,6 +55,7 @@ defmodule WhoNeedHelp.ContentRemoval.Notice do
field :decision_sent_at, :utc_datetime field :decision_sent_at, :utc_datetime
belongs_to :requester, WhoNeedHelp.Accounts.User belongs_to :requester, WhoNeedHelp.Accounts.User
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
belongs_to :assigned_to, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -103,7 +104,14 @@ defmodule WhoNeedHelp.ContentRemoval.Notice do
def moderation_changeset(notice, attrs) do def moderation_changeset(notice, attrs) do
notice notice
|> cast(attrs, [:status, :resolution_note, :reviewed_at, :reviewed_by_id, :decision_sent_at]) |> cast(attrs, [
:status,
:resolution_note,
:reviewed_at,
:reviewed_by_id,
:decision_sent_at,
:assigned_to_id
])
|> validate_required([:status, :reviewed_at, :reviewed_by_id]) |> validate_required([:status, :reviewed_at, :reviewed_by_id])
|> validate_length(:resolution_note, max: 10_000) |> validate_length(:resolution_note, max: 10_000)
end end

View File

@ -342,7 +342,7 @@ defmodule WhoNeedHelp.Help do
request.requester_id == user.id -> request.requester_id == user.id ->
{:ok, request} {:ok, request}
Accounts.moderator_authorized?(user) -> Accounts.authorized?(user, :moderation_view) ->
{:ok, request} {:ok, request}
not is_nil(request.hidden_at) -> not is_nil(request.hidden_at) ->

View File

@ -71,7 +71,7 @@ defmodule WhoNeedHelp.ProductAnalytics do
def increment_for_user(_user, _metric, _dimension), do: {:error, :invalid_user} def increment_for_user(_user, _metric, _dimension), do: {:error, :invalid_user}
def paginate(%Scope{user: user}, options \\ []) do def paginate(%Scope{user: user}, options \\ []) do
if Accounts.moderator_authorized?(user) do if Accounts.authorized?(user, :analytics_view) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)

View File

@ -33,7 +33,9 @@ defmodule WhoNeedHelp.Release do
admin_count = admin_count =
WhoNeedHelp.Repo.aggregate( WhoNeedHelp.Repo.aggregate(
from(user in WhoNeedHelp.Accounts.User, where: user.role == :admin), from(assignment in WhoNeedHelp.Accounts.StaffRoleAssignment,
where: assignment.role == :admin
),
:count :count
) )
@ -42,13 +44,16 @@ defmodule WhoNeedHelp.Release do
{:error, :admin_already_exists} {:error, :admin_already_exists}
user = WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) -> user = WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) ->
with {:ok, user} <- with {:ok, _assignment} <-
user %WhoNeedHelp.Accounts.StaffRoleAssignment{}
|> WhoNeedHelp.Accounts.User.role_changeset(%{role: :admin}) |> WhoNeedHelp.Accounts.StaffRoleAssignment.changeset(%{
|> WhoNeedHelp.Repo.update(), user_id: user.id,
role: :admin
})
|> WhoNeedHelp.Repo.insert(),
{:ok, _audit} <- {:ok, _audit} <-
WhoNeedHelp.Trust.audit(nil, "user.admin_bootstrapped", "user", user.id) do WhoNeedHelp.Trust.audit(nil, "user.admin_bootstrapped", "user", user.id) do
{:ok, %{id: user.id, email: user.email, role: user.role}} {:ok, %{id: user.id, email: user.email, roles: [:admin]}}
end end
true -> true ->

View File

@ -141,19 +141,23 @@ defmodule WhoNeedHelp.Support do
end end
def paginate_for_staff(%Scope{user: user}, options \\ []) do def paginate_for_staff(%Scope{user: user}, options \\ []) do
if Accounts.moderator_authorized?(user) do if Accounts.authorized?(user, :support_view) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
SupportRequest SupportRequest
|> where([request], not is_nil(request.contact_verified_at)) |> where([request], not is_nil(request.contact_verified_at))
|> maybe_kind(Keyword.get(options, :kind)) |> maybe_kind(Keyword.get(options, :kind))
|> maybe_status(Keyword.get(options, :status))
|> maybe_assignee(Keyword.get(options, :assigned_to_id), user.id)
|> maybe_search(Keyword.get(options, :search))
|> 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, :requester,
:reviewed_by, :reviewed_by,
:assigned_to,
conversation_messages: :sender, conversation_messages: :sender,
status_events: :actor status_events: :actor
]) ])
@ -166,10 +170,11 @@ defmodule WhoNeedHelp.Support do
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.moderator_authorized?(moderator) do true <- Accounts.authorized?(moderator, :support_manage) do
attrs = normalize_keys(attrs) attrs = normalize_keys(attrs)
response = normalize_message(attrs["response"] || attrs["resolution_note"]) response = normalize_message(attrs["response"] || attrs["resolution_note"])
with {:ok, attrs} <- normalize_assignment(attrs, :support_manage) do
attrs = attrs =
attrs attrs
|> Map.delete("response") |> Map.delete("response")
@ -211,7 +216,8 @@ defmodule WhoNeedHelp.Support do
"support_request", "support_request",
request.id, request.id,
%{ %{
"status" => to_string(request.status) "status" => to_string(request.status),
"assigned_to_id" => request.assigned_to_id
} }
) do ) do
{:ok, preload_conversation(request)} {:ok, preload_conversation(request)}
@ -222,6 +228,7 @@ defmodule WhoNeedHelp.Support do
end) end)
|> notify_decision() |> notify_decision()
|> broadcast_request_update() |> broadcast_request_update()
end
else else
false -> {:error, :forbidden} false -> {:error, :forbidden}
_ -> {:error, :not_found} _ -> {:error, :not_found}
@ -343,7 +350,7 @@ defmodule WhoNeedHelp.Support do
end end
def deletion_assessment(%Scope{user: moderator} = scope, id) do def deletion_assessment(%Scope{user: moderator} = scope, id) do
with true <- Accounts.moderator_authorized?(moderator), with true <- Accounts.authorized?(moderator, :support_manage),
{:ok, id} <- Ecto.UUID.cast(id), {:ok, id} <- Ecto.UUID.cast(id),
%SupportRequest{} = request <- %SupportRequest{} = request <-
Repo.one( Repo.one(
@ -487,7 +494,7 @@ defmodule WhoNeedHelp.Support do
defp preload_conversation(%SupportRequest{} = request) do defp preload_conversation(%SupportRequest{} = request) do
Repo.preload( Repo.preload(
request, request,
[conversation_messages: :sender, status_events: :actor], [:assigned_to, conversation_messages: :sender, status_events: :actor],
force: true force: true
) )
end end
@ -565,8 +572,54 @@ defmodule WhoNeedHelp.Support do
end end
defp maybe_kind(query, nil), do: query defp maybe_kind(query, nil), do: query
defp maybe_kind(query, ""), do: query
defp maybe_kind(query, kind), do: where(query, [request], request.kind == ^kind) defp maybe_kind(query, kind), do: where(query, [request], request.kind == ^kind)
defp maybe_status(query, nil), do: query
defp maybe_status(query, ""), do: query
defp maybe_status(query, status), do: where(query, [request], request.status == ^status)
defp maybe_assignee(query, nil, _current_user_id), do: query
defp maybe_assignee(query, "", _current_user_id), do: query
defp maybe_assignee(query, "unassigned", _current_user_id),
do: where(query, [r], is_nil(r.assigned_to_id))
defp maybe_assignee(query, "mine", current_user_id),
do: where(query, [r], r.assigned_to_id == ^current_user_id)
defp maybe_assignee(query, assignee_id, _current_user_id),
do: where(query, [r], r.assigned_to_id == ^assignee_id)
defp maybe_search(query, value) when value in [nil, ""], do: query
defp maybe_search(query, value) do
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
pattern = "%#{term}%"
where(
query,
[request],
ilike(request.reference, ^pattern) or ilike(request.contact_email, ^pattern) or
ilike(request.subject, ^pattern)
)
end
defp normalize_assignment(attrs, permission) do
case Map.fetch(attrs, "assigned_to_id") do
:error ->
{:ok, attrs}
{:ok, value} when value in [nil, ""] ->
{:ok, Map.put(attrs, "assigned_to_id", nil)}
{:ok, user_id} ->
if Accounts.authorized_user_id?(user_id, permission),
do: {:ok, attrs},
else: {:error, :invalid_assignee}
end
end
defp rate_scopes(user, contact_email, client_scope) do defp rate_scopes(user, contact_email, client_scope) do
[{:support_request, rate_scope(user, contact_email)}] [{:support_request, rate_scope(user, contact_email)}]
|> maybe_add_client_rate_scope(client_scope) |> maybe_add_client_rate_scope(client_scope)

View File

@ -41,6 +41,7 @@ defmodule WhoNeedHelp.Support.SupportRequest do
field :response_sent_at, :utc_datetime field :response_sent_at, :utc_datetime
belongs_to :requester, WhoNeedHelp.Accounts.User belongs_to :requester, WhoNeedHelp.Accounts.User
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
belongs_to :assigned_to, WhoNeedHelp.Accounts.User
has_many :conversation_messages, WhoNeedHelp.Support.ConversationMessage, has_many :conversation_messages, WhoNeedHelp.Support.ConversationMessage,
preload_order: [asc: :inserted_at, asc: :id] preload_order: [asc: :inserted_at, asc: :id]
@ -68,7 +69,14 @@ defmodule WhoNeedHelp.Support.SupportRequest do
def moderation_changeset(request, attrs) do def moderation_changeset(request, attrs) do
request request
|> cast(attrs, [:status, :resolution_note, :reviewed_at, :reviewed_by_id, :response_sent_at]) |> cast(attrs, [
:status,
:resolution_note,
:reviewed_at,
:reviewed_by_id,
:response_sent_at,
:assigned_to_id
])
|> validate_required([:status, :reviewed_at, :reviewed_by_id]) |> validate_required([:status, :reviewed_at, :reviewed_by_id])
|> validate_length(:resolution_note, max: 5_000) |> validate_length(:resolution_note, max: 5_000)
end end

View File

@ -332,7 +332,7 @@ defmodule WhoNeedHelp.Trust do
end end
def paginate_reports(%Scope{user: user}, status \\ nil, options \\ []) do def paginate_reports(%Scope{user: user}, status \\ nil, options \\ []) do
if Accounts.moderator_authorized?(user) do if Accounts.authorized?(user, :moderation_view) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
@ -359,7 +359,7 @@ defmodule WhoNeedHelp.Trust do
def moderate_report(%Scope{user: moderator}, report_id, attrs) do def moderate_report(%Scope{user: moderator}, report_id, attrs) do
with {:ok, report_id} <- cast_id(report_id), with {:ok, report_id} <- cast_id(report_id),
true <- Accounts.moderator_authorized?(moderator) do true <- Accounts.authorized?(moderator, :moderation_manage) do
attrs = attrs =
attrs attrs
|> stringify_keys() |> stringify_keys()
@ -413,7 +413,7 @@ defmodule WhoNeedHelp.Trust do
def report_evidence(%Scope{user: moderator}, report_id) do def report_evidence(%Scope{user: moderator}, report_id) do
with {:ok, report_id} <- cast_id(report_id), with {:ok, report_id} <- cast_id(report_id),
true <- Accounts.moderator_authorized?(moderator) do true <- Accounts.authorized?(moderator, :moderation_evidence_view) do
report = report =
Report Report
|> Repo.get(report_id) |> Repo.get(report_id)
@ -667,7 +667,7 @@ defmodule WhoNeedHelp.Trust do
end end
def paginate_abuse_signals(%Scope{user: user}, status \\ :open, options \\ []) do def paginate_abuse_signals(%Scope{user: user}, status \\ :open, options \\ []) do
if Accounts.moderator_authorized?(user) do if Accounts.authorized?(user, :moderation_view) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
@ -686,7 +686,7 @@ defmodule WhoNeedHelp.Trust do
def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do
with {:ok, signal_id} <- cast_id(signal_id), with {:ok, signal_id} <- cast_id(signal_id),
true <- Accounts.moderator_authorized?(moderator) do true <- Accounts.authorized?(moderator, :moderation_manage) do
attrs = attrs =
attrs attrs
|> stringify_keys() |> stringify_keys()
@ -723,7 +723,7 @@ defmodule WhoNeedHelp.Trust do
def hide_request(%Scope{user: moderator}, request_id, reason) do def hide_request(%Scope{user: moderator}, request_id, reason) do
result = result =
with {:ok, request_id} <- cast_id(request_id), with {:ok, request_id} <- cast_id(request_id),
true <- Accounts.moderator_authorized?(moderator) do true <- Accounts.authorized?(moderator, :moderation_manage) do
Repo.transact(fn -> Repo.transact(fn ->
request = request =
HelpRequest HelpRequest
@ -763,7 +763,7 @@ defmodule WhoNeedHelp.Trust do
def restore_request(%Scope{user: moderator}, request_id) do def restore_request(%Scope{user: moderator}, request_id) do
result = result =
with {:ok, request_id} <- cast_id(request_id), with {:ok, request_id} <- cast_id(request_id),
true <- Accounts.moderator_authorized?(moderator) do true <- Accounts.authorized?(moderator, :moderation_manage) do
Repo.transact(fn -> Repo.transact(fn ->
request = request =
HelpRequest HelpRequest
@ -798,7 +798,7 @@ defmodule WhoNeedHelp.Trust do
def hide_activity(%Scope{user: moderator}, activity_id, reason) do def hide_activity(%Scope{user: moderator}, activity_id, reason) do
result = result =
with {:ok, activity_id} <- cast_id(activity_id), with {:ok, activity_id} <- cast_id(activity_id),
true <- Accounts.moderator_authorized?(moderator) do true <- Accounts.authorized?(moderator, :moderation_manage) do
Repo.transact(fn -> Repo.transact(fn ->
activity = activity =
Activity Activity
@ -838,7 +838,7 @@ defmodule WhoNeedHelp.Trust do
def restore_activity(%Scope{user: moderator}, activity_id) do def restore_activity(%Scope{user: moderator}, activity_id) do
result = result =
with {:ok, activity_id} <- cast_id(activity_id), with {:ok, activity_id} <- cast_id(activity_id),
true <- Accounts.moderator_authorized?(moderator) do true <- Accounts.authorized?(moderator, :moderation_manage) do
Repo.transact(fn -> Repo.transact(fn ->
activity = activity =
Activity Activity
@ -910,16 +910,42 @@ defmodule WhoNeedHelp.Trust do
end end
end end
def moderate_role(%Scope{user: admin}, user_id, attrs) do def set_staff_roles(%Scope{user: admin}, user_id, roles) do
result =
Repo.transact(fn -> Repo.transact(fn ->
with {:ok, user} <- Accounts.change_user_role(admin, user_id, attrs), with {:ok, %{user: user} = change} <- Accounts.set_staff_roles(admin, user_id, roles),
{:ok, _audit} <- {:ok, _audit} <-
audit(admin.id, "user.role_changed", "user", user.id, %{ audit(admin.id, "user.staff_roles_changed", "user", user.id, %{
"role" => to_string(user.role) "roles" => Enum.map(Accounts.loaded_staff_roles(user), &to_string/1)
}) do }) do
{:ok, user} {:ok, change}
end end
end) end)
with {:ok, %{user: user, session_tokens: session_tokens}} <- result do
WhoNeedHelpWeb.UserAuth.disconnect_sessions(session_tokens)
{:ok, user}
end
end
def paginate_audit_events(%Scope{user: user}, options \\ []) do
if Accounts.authorized?(user, :audit_view) do
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
AuditEvent
|> maybe_audit_action(Keyword.get(options, :action))
|> maybe_audit_actor(Keyword.get(options, :actor_id))
|> maybe_audit_search(Keyword.get(options, :search))
|> before_audit_event(cursor)
|> order_by([event], desc: event.inserted_at, desc: event.id)
|> limit(^(limit + 1))
|> preload(:actor)
|> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
else
%Pagination.Page{}
end
end end
def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do
@ -934,6 +960,43 @@ defmodule WhoNeedHelp.Trust do
|> Repo.insert() |> Repo.insert()
end end
defp maybe_audit_action(query, value) when value in [nil, ""], do: query
defp maybe_audit_action(query, value), do: where(query, [event], event.action == ^value)
defp maybe_audit_actor(query, value) when value in [nil, ""], do: query
defp maybe_audit_actor(query, value) do
case cast_id(value) do
{:ok, actor_id} -> where(query, [event], event.actor_id == ^actor_id)
_ -> where(query, [event], false)
end
end
defp maybe_audit_search(query, value) when value in [nil, ""], do: query
defp maybe_audit_search(query, value) do
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
pattern = "%#{term}%"
where(
query,
[event],
ilike(event.action, ^pattern) or ilike(event.target_type, ^pattern) or
fragment("CAST(? AS text) ILIKE ?", event.target_id, ^pattern)
)
end
defp before_audit_event(query, nil), do: query
defp before_audit_event(query, {inserted_at, id}) do
where(
query,
[event],
event.inserted_at < ^inserted_at or
(event.inserted_at == ^inserted_at and event.id < ^id)
)
end
defp users_by_id([]), do: %{} defp users_by_id([]), do: %{}
defp users_by_id(ids) do defp users_by_id(ids) do

View File

@ -0,0 +1,139 @@
defmodule WhoNeedHelpWeb.AdminComponents do
use WhoNeedHelpWeb, :html
alias WhoNeedHelp.Accounts
attr :current_scope, :map, required: true
attr :flash, :map, required: true
attr :active, :atom, required: true
attr :title, :string, required: true
attr :description, :string, default: nil
slot :inner_block, required: true
def shell(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="grid gap-6 lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start">
<aside class="rounded-3xl border border-base-300 bg-base-100 p-3 lg:sticky lg:top-24">
<div class="px-3 pb-3 pt-2">
<p class="text-xs font-black uppercase tracking-[0.18em] text-primary">
{gettext("Staff workspace")}
</p>
<p class="mt-1 text-sm text-base-content/60">
{gettext("Tools shown here follow your assigned permissions.")}
</p>
</div>
<nav aria-label={gettext("Staff workspace navigation")}>
<ul class="menu gap-1 p-0">
<.nav_item active={@active == :dashboard} href={~p"/admin"} icon="hero-squares-2x2">
{gettext("Overview")}
</.nav_item>
<.nav_item
:if={Accounts.permission?(@current_scope.user, :users_view)}
active={@active == :users}
href={~p"/admin/users"}
icon="hero-users"
>
{gettext("Users and roles")}
</.nav_item>
<.nav_item
:if={
Accounts.permission?(@current_scope.user, :support_view) or
Accounts.permission?(@current_scope.user, :legal_view)
}
active={@active == :support}
href={~p"/support/operations"}
icon="hero-inbox-stack"
>
{support_nav_label(@current_scope.user)}
</.nav_item>
<.nav_item
:if={Accounts.permission?(@current_scope.user, :moderation_view)}
active={@active == :moderation}
href={~p"/moderation"}
icon="hero-shield-check"
>
{gettext("Trust and safety")}
</.nav_item>
<.nav_item
:if={Accounts.permission?(@current_scope.user, :analytics_view)}
active={@active == :analytics}
href={~p"/analytics"}
icon="hero-chart-bar"
>
{gettext("Analytics")}
</.nav_item>
<.nav_item
:if={Accounts.permission?(@current_scope.user, :audit_view)}
active={@active == :audit}
href={~p"/admin/audit"}
icon="hero-clipboard-document-check"
>
{gettext("Audit log")}
</.nav_item>
</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
attr :active, :boolean, required: true
attr :href, :string, required: true
attr :icon, :string, required: true
slot :inner_block, required: true
defp nav_item(assigns) do
~H"""
<li>
<.link
navigate={@href}
aria-current={@active && "page"}
class={[
"min-h-11 gap-3 rounded-xl font-semibold",
@active && "bg-primary text-primary-content"
]}
>
<.icon name={@icon} class="size-5 shrink-0" />
<span>{render_slot(@inner_block)}</span>
</.link>
</li>
"""
end
defp support_nav_label(user) do
case {
Accounts.permission?(user, :support_view),
Accounts.permission?(user, :legal_view)
} do
{true, true} -> gettext("Support and legal")
{true, false} -> gettext("Support")
{false, true} -> gettext("Legal and removal")
end
end
end

View File

@ -142,16 +142,13 @@ defmodule WhoNeedHelpWeb.Layouts do
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link> <.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li> </li>
<li <li
:if={@current_scope.user.role in [:moderator, :admin]} :if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}
class="menu-title mt-1 border-t border-base-300 pt-2" class="menu-title mt-1 border-t border-base-300 pt-2"
> >
{gettext("Operations")} {gettext("Operations")}
</li> </li>
<li :if={@current_scope.user.role in [:moderator, :admin]}> <li :if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}>
<.link navigate={~p"/moderation"}>{gettext("Moderation")}</.link> <.link navigate={~p"/admin"}>{gettext("Staff workspace")}</.link>
</li>
<li :if={@current_scope.user.role in [:moderator, :admin]}>
<.link navigate={~p"/support/operations"}>{gettext("Support queue")}</.link>
</li> </li>
<li> <li>
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link> <.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>
@ -387,16 +384,13 @@ defmodule WhoNeedHelpWeb.Layouts do
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link> <.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li> </li>
<li <li
:if={@current_scope.user.role in [:moderator, :admin]} :if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}
class="menu-title mt-1 border-t border-base-300 pt-2" class="menu-title mt-1 border-t border-base-300 pt-2"
> >
{gettext("Operations")} {gettext("Operations")}
</li> </li>
<li :if={@current_scope.user.role in [:moderator, :admin]}> <li :if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}>
<.link navigate={~p"/moderation"}>{gettext("Moderation")}</.link> <.link navigate={~p"/admin"}>{gettext("Staff workspace")}</.link>
</li>
<li :if={@current_scope.user.role in [:moderator, :admin]}>
<.link navigate={~p"/support/operations"}>{gettext("Support queue")}</.link>
</li> </li>
<li> <li>
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link> <.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>

View File

@ -0,0 +1,186 @@
defmodule WhoNeedHelpWeb.AdminAuditLive do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.Trust
alias WhoNeedHelpWeb.AdminComponents
@impl true
def mount(_params, _session, socket) do
filters = %{"search" => "", "action" => "", "actor_id" => ""}
{:ok,
socket
|> assign(:page_title, gettext("Audit log"))
|> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filters))
|> load_events()}
end
@impl true
def handle_event("filter", %{"filters" => filters}, socket) do
filters = Map.merge(socket.assigns.filters, filters)
{:noreply,
socket
|> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filters))
|> load_events()}
end
def handle_event("load-more", _params, socket) do
page =
Trust.paginate_audit_events(
socket.assigns.current_scope,
event_options(socket, socket.assigns.events_cursor)
)
existing_ids = MapSet.new(socket.assigns.events, & &1.id)
{:noreply,
socket
|> assign(
:events,
socket.assigns.events ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
)
|> assign(:events_cursor, page.next_cursor)}
end
defp load_events(socket) do
page = Trust.paginate_audit_events(socket.assigns.current_scope, event_options(socket))
socket |> assign(:events, page.entries) |> assign(:events_cursor, page.next_cursor)
end
defp event_options(socket, cursor \\ nil) do
filters = socket.assigns.filters
[
search: filters["search"],
action: filters["action"],
actor_id: filters["actor_id"],
after: cursor
]
end
@impl true
def render(assigns) do
~H"""
<AdminComponents.shell
flash={@flash}
current_scope={@current_scope}
active={:audit}
title={gettext("Audit log")}
description={
gettext(
"Immutable records of sensitive staff and trust actions. Metadata is shown exactly as stored."
)
}
>
<.form
for={@filter_form}
id="admin-audit-filters"
phx-change="filter"
class="grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-3"
>
<.input
field={@filter_form[:search]}
type="search"
label={gettext("Target or action contains")}
phx-debounce="300"
/>
<.input
field={@filter_form[:action]}
label={gettext("Exact action")}
placeholder="user.staff_roles_changed"
phx-debounce="300"
/>
<.input field={@filter_form[:actor_id]} label={gettext("Actor UUID")} phx-debounce="300" />
</.form>
<div class="space-y-3 md:hidden">
<p
:if={@events == []}
class="rounded-3xl border border-dashed border-base-300 p-10 text-center text-base-content/60"
>
{gettext("No audit events match these filters.")}
</p>
<article
:for={event <- @events}
class="rounded-3xl border border-base-300 bg-base-100 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<code class="break-all text-xs font-bold text-primary">{event.action}</code>
<time class="text-xs text-base-content/55">{event.inserted_at}</time>
</div>
<dl class="mt-4 grid gap-3 text-sm">
<div>
<dt class="text-xs font-bold uppercase tracking-wide text-base-content/45">
{gettext("Actor")}
</dt>
<dd class="mt-1 break-all">
{(event.actor && (event.actor.display_name || event.actor.email)) || gettext("System")}
</dd>
</div>
<div>
<dt class="text-xs font-bold uppercase tracking-wide text-base-content/45">
{gettext("Target")}
</dt>
<dd class="mt-1 break-all font-mono text-xs">
{event.target_type}: {event.target_id}
</dd>
</div>
<div>
<dt class="text-xs font-bold uppercase tracking-wide text-base-content/45">
{gettext("Metadata")}
</dt>
<dd>
<pre class="mt-1 whitespace-pre-wrap break-words rounded-2xl bg-base-200 p-3 text-xs">{Jason.encode!(event.metadata, pretty: true)}</pre>
</dd>
</div>
</dl>
</article>
</div>
<div class="hidden overflow-x-auto rounded-3xl border border-base-300 bg-base-100 md:block">
<table class="table">
<thead>
<tr>
<th>{gettext("Time")}</th><th>{gettext("Actor")}</th><th>{gettext("Action")}</th><th>
{gettext("Target")}
</th><th>{gettext("Metadata")}</th>
</tr>
</thead>
<tbody>
<tr :if={@events == []}>
<td colspan="5" class="py-10 text-center text-base-content/60">
{gettext("No audit events match these filters.")}
</td>
</tr>
<tr :for={event <- @events}>
<td class="whitespace-nowrap text-xs">{event.inserted_at}</td>
<td>
<span class="font-semibold">{(event.actor &&
(event.actor.display_name || event.actor.email)) ||
gettext("System")}</span><div :if={event.actor} class="text-xs opacity-55">
{event.actor_id}
</div>
</td>
<td class="font-mono text-xs">{event.action}</td>
<td>
<span>{event.target_type}</span><div class="font-mono text-xs opacity-55">
{event.target_id}
</div>
</td>
<td>
<pre class="max-w-md whitespace-pre-wrap break-words text-xs">{Jason.encode!(event.metadata, pretty: true)}</pre>
</td>
</tr>
</tbody>
</table>
</div>
<button :if={@events_cursor} phx-click="load-more" class="btn btn-outline">{gettext(
"Load more events"
)}</button>
</AdminComponents.shell>
"""
end
end

View File

@ -0,0 +1,136 @@
defmodule WhoNeedHelpWeb.AdminDashboardLive do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Accounts, Admin}
alias WhoNeedHelpWeb.AdminComponents
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, gettext("Staff workspace"))
|> assign(:summary, Admin.dashboard(socket.assigns.current_scope))}
end
@impl true
def render(assigns) do
~H"""
<AdminComponents.shell
flash={@flash}
current_scope={@current_scope}
active={:dashboard}
title={gettext("Operations overview")}
description={
gettext(
"A single starting point for users, support, safety, legal work, analytics, roles, and audited staff actions."
)
}
>
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<.metric_card
:if={Map.has_key?(@summary, :support_open)}
value={@summary[:support_open]}
label={gettext("Verified support cases")}
href={~p"/support/operations"}
icon="hero-chat-bubble-left-right"
/>
<.metric_card
:if={Map.has_key?(@summary, :legal_open)}
value={@summary[:legal_open]}
label={gettext("Legal and removal cases")}
href={~p"/support/operations"}
icon="hero-scale"
/>
<.metric_card
:if={Map.has_key?(@summary, :reports_open)}
value={@summary[:reports_open]}
label={gettext("Open reports")}
href={~p"/moderation"}
icon="hero-flag"
/>
<.metric_card
:if={Map.has_key?(@summary, :signals_open)}
value={@summary[:signals_open]}
label={gettext("Open trust signals")}
href={~p"/moderation"}
icon="hero-exclamation-triangle"
/>
<.metric_card
:if={Map.has_key?(@summary, :category_proposals_open)}
value={@summary[:category_proposals_open]}
label={gettext("Category proposals")}
href={~p"/moderation"}
icon="hero-tag"
/>
<.metric_card
:if={Map.has_key?(@summary, :users_total)}
value={@summary[:users_total]}
label={gettext("Registered users")}
href={~p"/admin/users"}
icon="hero-users"
/>
<.metric_card
:if={Map.has_key?(@summary, :staff_total)}
value={@summary[:staff_total]}
label={gettext("Staff accounts")}
href={~p"/admin/users"}
icon="hero-identification"
/>
</section>
<section class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-black">{gettext("Your staff access")}</h2>
<p class="mt-1 text-sm text-base-content/60">
{gettext(
"An account may hold several roles. Permissions are the union of those roles; administrator includes every permission."
)}
</p>
</div>
<div class="flex flex-wrap gap-2">
<span
:for={role <- Accounts.loaded_staff_roles(@current_scope.user)}
class="badge badge-primary badge-outline h-8 px-3 font-semibold"
>
{role_label(role)}
</span>
</div>
</div>
</section>
</AdminComponents.shell>
"""
end
attr :value, :integer, required: true
attr :label, :string, required: true
attr :href, :string, required: true
attr :icon, :string, required: true
defp metric_card(assigns) do
~H"""
<.link
navigate={@href}
class="group rounded-3xl border border-base-300 bg-base-100 p-5 transition hover:border-primary/40 hover:shadow-md"
>
<div class="flex items-start justify-between gap-4">
<span class="grid size-11 place-items-center rounded-2xl bg-primary/10 text-primary">
<.icon name={@icon} class="size-5" />
</span>
<.icon
name="hero-arrow-up-right"
class="size-5 opacity-40 transition group-hover:opacity-100"
/>
</div>
<p class="mt-5 text-3xl font-black tabular-nums">{@value}</p>
<p class="mt-1 text-sm font-semibold text-base-content/65">{@label}</p>
</.link>
"""
end
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")
end

View File

@ -0,0 +1,293 @@
defmodule WhoNeedHelpWeb.AdminUsersLive do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Accounts, Trust}
alias WhoNeedHelp.Accounts.StaffPermissions
alias WhoNeedHelpWeb.AdminComponents
@impl true
def mount(_params, _session, socket) do
filters = %{"search" => "", "status" => "", "role" => ""}
{:ok,
socket
|> assign(:page_title, gettext("Users and roles"))
|> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filters))
|> assign(:roles, StaffPermissions.roles())
|> load_users()}
end
@impl true
def handle_event("filter", %{"filters" => filters}, socket) do
filters = Map.merge(socket.assigns.filters, filters)
{:noreply,
socket
|> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filters))
|> load_users()}
end
def handle_event("load-more", _params, socket) do
page =
Accounts.paginate_users_for_moderation(
socket.assigns.current_scope,
user_options(socket, socket.assigns.users_cursor)
)
existing_ids = MapSet.new(socket.assigns.users, & &1.id)
{:noreply,
socket
|> assign(
:users,
socket.assigns.users ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
)
|> assign(:users_cursor, page.next_cursor)}
end
def handle_event("moderate-user", %{"id" => id, "moderation" => params}, socket) do
respond(
socket,
Trust.moderate_user(socket.assigns.current_scope, id, params),
gettext("Account status updated.")
)
end
def handle_event("set-staff-roles", %{"id" => id, "staff" => params}, socket) do
roles = params |> Map.get("roles", []) |> List.wrap() |> Enum.reject(&(&1 == ""))
respond(
socket,
Trust.set_staff_roles(socket.assigns.current_scope, id, roles),
gettext("Staff roles updated.")
)
end
defp respond(socket, {:ok, _record}, message),
do: {:noreply, socket |> put_flash(:info, message) |> load_users()}
defp respond(socket, {:error, reason}, _message),
do: {:noreply, put_flash(socket, :error, error_message(reason))}
defp load_users(socket) do
page =
Accounts.paginate_users_for_moderation(socket.assigns.current_scope, user_options(socket))
socket |> assign(:users, page.entries) |> assign(:users_cursor, page.next_cursor)
end
defp user_options(socket, cursor \\ nil) do
filters = socket.assigns.filters
[search: filters["search"], status: filters["status"], role: filters["role"], after: cursor]
end
defp error_message(:forbidden),
do:
gettext(
"You do not have permission for this action. Role changes also require a recent sign-in."
)
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 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("Users and staff access")}
description={
gettext(
"Search accounts, restrict or suspend access, and combine fixed staff roles without creating ambiguous custom permissions."
)
}
>
<.form
for={@filter_form}
id="admin-user-filters"
phx-change="filter"
class="grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-[minmax(0,1fr)_12rem_12rem]"
>
<.input
field={@filter_form[:search]}
type="search"
label={gettext("Search users")}
placeholder={gettext("Email or display name")}
phx-debounce="300"
/>
<.input
field={@filter_form[:status]}
type="select"
label={gettext("Account status")}
options={[
{gettext("Any status"), ""},
{gettext("Active"), "active"},
{gettext("Restricted"), "restricted"},
{gettext("Suspended"), "suspended"}
]}
/>
<.input
field={@filter_form[:role]}
type="select"
label={gettext("Staff role")}
options={
[{gettext("Any role"), ""}, {gettext("No staff role"), "user"}] ++
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
field={status_form[:moderation_status]}
type="select"
label={gettext("Status")}
options={[
{gettext("Active"), "active"},
{gettext("Restricted"), "restricted"},
{gettext("Suspended"), "suspended"}
]}
/>
<.input
field={status_form[:moderation_note]}
label={gettext("Internal note")}
maxlength="1000"
/>
<button class="btn btn-primary self-end" phx-disable-with={gettext("Saving…")}>{gettext(
"Save"
)}</button>
</.form>
</div>
<.form
:if={Accounts.permission?(@current_scope.user, :staff_manage)}
for={to_form(%{}, as: :staff, id: "roles-#{user.id}")}
id={"staff-roles-#{user.id}"}
phx-submit="set-staff-roles"
phx-value-id={user.id}
class="mt-5 border-t border-base-300 pt-5"
>
<div class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<fieldset class="grid min-w-0 flex-1 gap-2 sm:grid-cols-2 xl:grid-cols-5">
<legend class="sr-only">{gettext("Staff roles")}</legend>
<input type="hidden" name="staff[roles][]" value="" />
<label
:for={role <- @roles}
class="flex cursor-pointer items-start gap-3 rounded-2xl border border-base-300 p-3 hover:border-primary/40"
>
<input
type="checkbox"
name="staff[roles][]"
value={Atom.to_string(role)}
checked={role in Accounts.loaded_staff_roles(user)}
class="checkbox checkbox-primary mt-0.5"
/>
<span class="min-w-0">
<span class="block font-bold">{role_label(role)}</span>
<span class="mt-0.5 block text-xs leading-5 text-base-content/55">{role_description(
role
)}</span>
</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.")}
</p>
</div>
<button :if={@users_cursor} phx-click="load-more" class="btn btn-outline">{gettext(
"Load more users"
)}</button>
</AdminComponents.shell>
"""
end
end

View File

@ -1,7 +1,8 @@
defmodule WhoNeedHelpWeb.ModerationLive do defmodule WhoNeedHelpWeb.ModerationLive do
use WhoNeedHelpWeb, :live_view use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Accounts, Catalog, Trust} alias WhoNeedHelp.{Catalog, Trust}
alias WhoNeedHelpWeb.AdminComponents
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -35,22 +36,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
) )
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("User status updated.")
)
end
def handle_event("moderate-role", %{"id" => id, "moderation" => params}, socket) do
respond(
socket,
Trust.moderate_role(socket.assigns.current_scope, id, params),
gettext("User role updated.")
)
end
def handle_event("hide-request", %{"id" => id, "moderation" => %{"note" => note}}, socket) do def handle_event("hide-request", %{"id" => id, "moderation" => %{"note" => note}}, socket) do
respond( respond(
socket, socket,
@ -173,15 +158,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
{:noreply, append_page(socket, :proposals, page)} {:noreply, append_page(socket, :proposals, page)}
end end
def handle_event("load-more-users", _params, socket) do
page =
Accounts.paginate_users_for_moderation(socket.assigns.current_scope,
after: socket.assigns.users_cursor
)
{:noreply, append_page(socket, :users, page)}
end
defp respond(socket, {:ok, _value}, message) do defp respond(socket, {:ok, _value}, message) do
{:noreply, socket |> put_flash(:info, message) |> load()} {:noreply, socket |> put_flash(:info, message) |> load()}
end end
@ -198,7 +174,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
reports = Trust.paginate_reports(socket.assigns.current_scope) reports = Trust.paginate_reports(socket.assigns.current_scope)
signals = Trust.paginate_abuse_signals(socket.assigns.current_scope) signals = Trust.paginate_abuse_signals(socket.assigns.current_scope)
proposals = Catalog.paginate_proposals_for_moderation(socket.assigns.current_scope) proposals = Catalog.paginate_proposals_for_moderation(socket.assigns.current_scope)
users = Accounts.paginate_users_for_moderation(socket.assigns.current_scope)
socket socket
|> assign(:reports, reports.entries) |> assign(:reports, reports.entries)
@ -207,8 +182,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|> assign(:signals_cursor, signals.next_cursor) |> assign(:signals_cursor, signals.next_cursor)
|> assign(:proposals, proposals.entries) |> assign(:proposals, proposals.entries)
|> assign(:proposals_cursor, proposals.next_cursor) |> assign(:proposals_cursor, proposals.next_cursor)
|> assign(:users, users.entries)
|> assign(:users_cursor, users.next_cursor)
|> assign(:categories, Catalog.list_all_categories()) |> assign(:categories, Catalog.list_all_categories())
end end
@ -225,7 +198,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp cursor_assign(:reports), do: :reports_cursor defp cursor_assign(:reports), do: :reports_cursor
defp cursor_assign(:signals), do: :signals_cursor defp cursor_assign(:signals), do: :signals_cursor
defp cursor_assign(:proposals), do: :proposals_cursor defp cursor_assign(:proposals), do: :proposals_cursor
defp cursor_assign(:users), do: :users_cursor
defp error_message(:forbidden), do: gettext("Moderator access is required.") defp error_message(:forbidden), do: gettext("Moderator access is required.")
defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.") defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.")
@ -268,11 +240,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp status_label(:suspended), do: gettext("Suspended") defp status_label(:suspended), do: gettext("Suspended")
defp status_label(value), do: to_string(value) defp status_label(value), do: to_string(value)
defp role_label(:user), do: gettext("User")
defp role_label(:moderator), do: gettext("Moderator")
defp role_label(:admin), do: gettext("Administrator")
defp role_label(value), do: to_string(value)
defp signal_label(:velocity), do: gettext("Unusual action rate") defp signal_label(:velocity), do: gettext("Unusual action rate")
defp signal_label(:repeated_pair), do: gettext("Repeated participant pair") defp signal_label(:repeated_pair), do: gettext("Repeated participant pair")
@ -316,30 +283,20 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp report_activity(_report), do: nil defp report_activity(_report), do: nil
defp can_moderate_account?(%{role: :admin}, _target), do: true
defp can_moderate_account?(%{role: :moderator}, %{role: :user}), do: true
defp can_moderate_account?(_actor, _target), do: false
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}> <AdminComponents.shell
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div> flash={@flash}
<div class="flex flex-wrap items-end justify-between gap-3"> current_scope={@current_scope}
<h1 class="mt-1 text-4xl font-black">{gettext("Moderation")}</h1> active={:moderation}
<div class="flex flex-wrap gap-2"> title={gettext("Trust and safety")}
<.link navigate={~p"/analytics"} class="btn btn-outline btn-sm"> description={
{gettext("Product analytics")} gettext(
</.link> "Review reports and automated abuse signals, inspect only scoped evidence, and moderate category proposals. Every sensitive action is audited."
<.link navigate={~p"/support/operations"} class="btn btn-outline btn-sm"> )
{gettext("Support operations")} }
</.link> >
</div>
</div>
<p class="mt-2 text-base-content/60">
{gettext("Decisions and access to reported chat evidence are written to the audit log.")}
</p>
<section class="mt-8"> <section class="mt-8">
<h2 class="text-2xl font-bold">{gettext("Reports")}</h2> <h2 class="text-2xl font-bold">{gettext("Reports")}</h2>
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
@ -651,106 +608,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
{gettext("Load more")} {gettext("Load more")}
</button> </button>
</section> </section>
</AdminComponents.shell>
<section class="mt-10">
<h2 class="text-2xl font-bold">{gettext("Accounts")}</h2>
<div class="mt-4 overflow-x-auto rounded-2xl border border-base-300">
<table class="table">
<thead>
<tr>
<th>{gettext("User")}</th>
<th>{gettext("Role")}</th>
<th>{gettext("Status")}</th>
<th>{gettext("Decision")}</th>
</tr>
</thead>
<tbody>
<tr :for={user <- @users}>
<% user_form =
scoped_form(
%{
"moderation_status" => to_string(user.moderation_status),
"moderation_note" => user.moderation_note || ""
},
:moderation,
"user-status-#{user.id}"
) %>
<% role_form =
scoped_form(
%{"role" => to_string(user.role)},
:moderation,
"user-role-#{user.id}"
) %>
<td>
{user.display_name}
<div class="text-xs opacity-50">{user.email}</div>
</td>
<td>{role_label(user.role)}</td>
<td>{status_label(user.moderation_status)}</td>
<td>
<.form
:if={can_moderate_account?(@current_scope.user, user)}
for={user_form}
phx-submit="moderate-user"
phx-value-id={user.id}
class="flex min-w-[28rem] gap-2"
>
<.input
field={user_form[:moderation_status]}
type="select"
options={[
{gettext("Active"), "active"},
{gettext("Restricted"), "restricted"},
{gettext("Suspended"), "suspended"}
]}
/>
<.input
field={user_form[:moderation_note]}
placeholder={gettext("Internal note")}
/>
<.button class="btn btn-sm btn-primary self-end">{gettext("Save")}</.button>
</.form>
<p
:if={not can_moderate_account?(@current_scope.user, user)}
class="text-xs text-base-content/55"
>
{gettext("Administrator access is required to moderate staff accounts.")}
</p>
<.form
:if={@current_scope.user.role == :admin}
for={role_form}
phx-submit="moderate-role"
phx-value-id={user.id}
class="mt-2 flex gap-2"
>
<.input
field={role_form[:role]}
type="select"
options={[
{gettext("User"), "user"},
{gettext("Moderator"), "moderator"},
{gettext("Administrator"), "admin"}
]}
/>
<.button class="btn btn-sm btn-warning self-end">
{gettext("Change role")}
</.button>
</.form>
</td>
</tr>
</tbody>
</table>
</div>
<button
:if={@users_cursor}
type="button"
phx-click="load-more-users"
class="btn btn-outline btn-sm mt-4"
>
{gettext("Load more")}
</button>
</section>
</Layouts.app>
""" """
end end
end end

View File

@ -2,6 +2,7 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
use WhoNeedHelpWeb, :live_view use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.ProductAnalytics alias WhoNeedHelp.ProductAnalytics
alias WhoNeedHelpWeb.AdminComponents
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -35,23 +36,18 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}> <AdminComponents.shell
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div> flash={@flash}
<div class="flex flex-wrap items-end justify-between gap-3"> current_scope={@current_scope}
<div> active={:analytics}
<h1 class="mt-1 text-4xl font-black">{gettext("Product analytics")}</h1> title={gettext("Product analytics")}
<p class="mt-2 text-base-content/60"> description={
{gettext( gettext(
"Daily aggregate counters only. No user ID, email, coordinate, request text, chat text, medicine name, or device credential is stored here." "Daily aggregate counters only. No user ID, email, coordinate, request text, chat text, medicine name, or device credential is stored here."
)} )
</p> }
</div> >
<.link navigate={~p"/moderation"} class="btn btn-outline btn-sm"> <div class="overflow-x-auto rounded-3xl border border-base-300 bg-base-100">
{gettext("Back to moderation")}
</.link>
</div>
<div class="mt-8 overflow-x-auto rounded-3xl border border-base-300">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -81,11 +77,11 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
:if={@metrics_cursor} :if={@metrics_cursor}
type="button" type="button"
phx-click="load-more" phx-click="load-more"
class="btn btn-outline btn-sm mt-4" class="btn btn-outline btn-sm"
> >
{gettext("Load more")} {gettext("Load more")}
</button> </button>
</Layouts.app> </AdminComponents.shell>
""" """
end end
end end

View File

@ -1,16 +1,33 @@
defmodule WhoNeedHelpWeb.SupportOperationsLive do defmodule WhoNeedHelpWeb.SupportOperationsLive do
use WhoNeedHelpWeb, :live_view use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{ContentRemoval, Support} alias WhoNeedHelp.{Accounts, ContentRemoval, Support}
alias WhoNeedHelpWeb.AdminComponents
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
if connected?(socket), do: Support.subscribe_staff() support_allowed = Accounts.permission?(socket.assigns.current_scope.user, :support_view)
legal_allowed = Accounts.permission?(socket.assigns.current_scope.user, :legal_view)
if connected?(socket) and support_allowed, do: Support.subscribe_staff()
support_filters = %{"search" => "", "status" => "", "kind" => "", "assigned_to_id" => ""}
legal_filters = %{"search" => "", "status" => "", "regime" => "", "assigned_to_id" => ""}
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Support operations")) |> assign(:page_title, gettext("Support operations"))
|> assign(:operations_title, operations_title(support_allowed, legal_allowed))
|> assign(:operations_description, operations_description(support_allowed, legal_allowed))
|> assign(:deletion_assessments, %{}) |> assign(:deletion_assessments, %{})
|> assign(:support_allowed, support_allowed)
|> assign(:legal_allowed, legal_allowed)
|> assign(:support_filters, support_filters)
|> assign(:legal_filters, legal_filters)
|> assign(:support_filter_form, to_form(support_filters, as: :support_filters))
|> assign(:legal_filter_form, to_form(legal_filters, as: :legal_filters))
|> assign(:support_assignees, staff_assignees(support_allowed, :support_manage))
|> assign(:legal_assignees, staff_assignees(legal_allowed, :legal_manage))
|> load()} |> load()}
end end
@ -36,6 +53,26 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
) )
end end
def handle_event("filter-support", %{"support_filters" => filters}, socket) do
filters = Map.merge(socket.assigns.support_filters, filters)
{:noreply,
socket
|> assign(:support_filters, filters)
|> assign(:support_filter_form, to_form(filters, as: :support_filters))
|> load_support()}
end
def handle_event("filter-legal", %{"legal_filters" => filters}, socket) do
filters = Map.merge(socket.assigns.legal_filters, filters)
{:noreply,
socket
|> assign(:legal_filters, filters)
|> assign(:legal_filter_form, to_form(filters, as: :legal_filters))
|> load_removals()}
end
def handle_event("assess-deletion", %{"id" => id}, socket) do def handle_event("assess-deletion", %{"id" => id}, socket) do
case Support.deletion_assessment(socket.assigns.current_scope, id) do case Support.deletion_assessment(socket.assigns.current_scope, id) do
{:ok, assessment} -> {:ok, assessment} ->
@ -48,8 +85,9 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
def handle_event("load-more-support", _params, socket) do def handle_event("load-more-support", _params, socket) do
page = page =
Support.paginate_for_staff(socket.assigns.current_scope, Support.paginate_for_staff(
after: socket.assigns.support_cursor socket.assigns.current_scope,
support_options(socket, socket.assigns.support_cursor)
) )
{:noreply, append_page(socket, :support_requests, :support_cursor, page)} {:noreply, append_page(socket, :support_requests, :support_cursor, page)}
@ -57,8 +95,9 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
def handle_event("load-more-removals", _params, socket) do def handle_event("load-more-removals", _params, socket) do
page = page =
ContentRemoval.paginate_for_staff(socket.assigns.current_scope, ContentRemoval.paginate_for_staff(
after: socket.assigns.removal_cursor socket.assigns.current_scope,
legal_options(socket, socket.assigns.removal_cursor)
) )
{:noreply, append_page(socket, :removal_notices, :removal_cursor, page)} {:noreply, append_page(socket, :removal_notices, :removal_cursor, page)}
@ -73,14 +112,69 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
end end
defp load(socket) do defp load(socket) do
support = Support.paginate_for_staff(socket.assigns.current_scope)
removals = ContentRemoval.paginate_for_staff(socket.assigns.current_scope)
socket socket
|> assign(:support_requests, support.entries) |> load_support()
|> assign(:support_cursor, support.next_cursor) |> load_removals()
|> assign(:removal_notices, removals.entries) end
|> assign(:removal_cursor, removals.next_cursor)
defp load_support(%{assigns: %{support_allowed: false}} = socket) do
socket |> assign(:support_requests, []) |> assign(:support_cursor, nil)
end
defp load_support(socket) do
page = Support.paginate_for_staff(socket.assigns.current_scope, support_options(socket))
socket |> assign(:support_requests, page.entries) |> assign(:support_cursor, page.next_cursor)
end
defp load_removals(%{assigns: %{legal_allowed: false}} = socket) do
socket |> assign(:removal_notices, []) |> assign(:removal_cursor, nil)
end
defp load_removals(socket) do
page = ContentRemoval.paginate_for_staff(socket.assigns.current_scope, legal_options(socket))
socket |> assign(:removal_notices, page.entries) |> assign(:removal_cursor, page.next_cursor)
end
defp support_options(socket, cursor \\ nil) do
filters = socket.assigns.support_filters
[
search: filters["search"],
status: filters["status"],
kind: filters["kind"],
assigned_to_id: filters["assigned_to_id"],
after: cursor
]
end
defp legal_options(socket, cursor \\ nil) do
filters = socket.assigns.legal_filters
[
search: filters["search"],
status: filters["status"],
regime: filters["regime"],
assigned_to_id: filters["assigned_to_id"],
after: cursor
]
end
defp staff_assignees(false, _permission), do: []
defp staff_assignees(true, permission), do: Accounts.list_staff_for_permission(permission)
defp assignee_options(users) do
[
{gettext("Any assignee"), ""},
{gettext("Unassigned"), "unassigned"},
{gettext("Assigned to me"), "mine"}
| Enum.map(users, fn user ->
{user.display_name || user.email, user.id}
end)
]
end
defp assignment_options(users) do
[{gettext("Unassigned"), ""} | Enum.map(users, &{&1.display_name || &1.email, &1.id})]
end end
defp append_page(socket, entries_key, cursor_key, page) do defp append_page(socket, entries_key, cursor_key, page) do
@ -97,6 +191,10 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
defp error_message(:account_not_linked), do: gettext("The request is not linked to an account.") 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(: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(: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(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
defp error_message(_reason), do: gettext("Could not update the request. Please try again.") defp error_message(_reason), do: gettext("Could not update the request. Please try again.")
@ -129,27 +227,86 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
defp scoped_form(data, id), do: to_form(data, as: :moderation, id: id) defp scoped_form(data, id), do: to_form(data, as: :moderation, id: id)
defp operations_title(true, true), do: gettext("Support and legal operations")
defp operations_title(true, false), do: gettext("Support operations")
defp operations_title(false, true), do: gettext("Legal and content removal operations")
defp operations_description(true, true) do
gettext(
"Verified support conversations and legal removal notices stay in separate permission-scoped queues. Assignments, decisions, and operator identity are audited."
)
end
defp operations_description(true, false) do
gettext(
"Verified support conversations stay in a permission-scoped queue. Assignments, replies, decisions, and operator identity are audited."
)
end
defp operations_description(false, true) do
gettext(
"Verified legal and content-removal notices stay in a permission-scoped queue. Assignments, decisions, and operator identity are audited."
)
end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}> <AdminComponents.shell
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div> flash={@flash}
<div class="flex flex-wrap items-end justify-between gap-3"> current_scope={@current_scope}
<div> active={:support}
<h1 class="mt-1 text-4xl font-black">{gettext("Support operations")}</h1> title={@operations_title}
<p class="mt-2 text-base-content/60"> description={@operations_description}
{gettext( >
"Support requests and legal removal notices are separate queues. Decisions and operator identity are audited." <section :if={@legal_allowed}>
)}
</p>
</div>
<.link navigate={~p"/moderation"} class="btn btn-outline btn-sm">
{gettext("Trust and safety moderation")}
</.link>
</div>
<section class="mt-8">
<h2 class="text-2xl font-bold">{gettext("Content removal and TAKE IT DOWN")}</h2> <h2 class="text-2xl font-bold">{gettext("Content removal and TAKE IT DOWN")}</h2>
<.form
for={@legal_filter_form}
id="legal-case-filters"
phx-change="filter-legal"
class="mt-4 grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-4"
>
<.input
field={@legal_filter_form[:search]}
type="search"
label={gettext("Search legal cases")}
placeholder={gettext("Reference, email, URL, or explanation")}
phx-debounce="300"
/>
<.input
field={@legal_filter_form[:status]}
type="select"
label={gettext("Status")}
options={[
{gettext("Any status"), ""},
{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={@legal_filter_form[:regime]}
type="select"
label={gettext("Regime")}
options={[
{gettext("Any regime"), ""},
{gettext("General"), "general"},
{gettext("DSA"), "dsa"},
{gettext("TAKE IT DOWN"), "take_it_down"}
]}
/>
<.input
field={@legal_filter_form[:assigned_to_id]}
type="select"
label={gettext("Assignee")}
options={assignee_options(@legal_assignees)}
/>
</.form>
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
<article <article
:for={notice <- @removal_notices} :for={notice <- @removal_notices}
@ -163,6 +320,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
scoped_form( scoped_form(
%{ %{
"status" => to_string(notice.status), "status" => to_string(notice.status),
"assigned_to_id" => notice.assigned_to_id || "",
"resolution_note" => notice.resolution_note || "" "resolution_note" => notice.resolution_note || ""
}, },
"removal-#{notice.id}" "removal-#{notice.id}"
@ -178,6 +336,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
<span :if={notice.response_due_at} class="badge badge-error badge-outline"> <span :if={notice.response_due_at} class="badge badge-error badge-outline">
{gettext("review by %{time}", time: notice.response_due_at)} {gettext("review by %{time}", time: notice.response_due_at)}
</span> </span>
<span class="badge badge-ghost">
{if notice.assigned_to,
do:
gettext("Assigned: %{name}",
name: notice.assigned_to.display_name || notice.assigned_to.email
),
else: gettext("Unassigned")}
</span>
</div> </div>
<dl class="mt-4 grid gap-2 text-sm sm:grid-cols-[10rem_1fr]"> <dl class="mt-4 grid gap-2 text-sm sm:grid-cols-[10rem_1fr]">
<dt class="font-semibold">{gettext("Submitter")}</dt> <dt class="font-semibold">{gettext("Submitter")}</dt>
@ -226,7 +392,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Audited update")} {gettext("Audited update")}
</span> </span>
</div> </div>
<div class="grid gap-5 p-4 xl:grid-cols-[15rem_minmax(0,1fr)] xl:items-start"> <div class="grid gap-5 p-4 xl:grid-cols-[15rem_15rem_minmax(0,1fr)] xl:items-start">
<div class="min-w-0"> <div class="min-w-0">
<.input <.input
field={form[:status]} field={form[:status]}
@ -247,6 +413,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Choose the state that will appear in the case history.")} {gettext("Choose the state that will appear in the case history.")}
</p> </p>
</div> </div>
<div class="min-w-0">
<.input
field={form[:assigned_to_id]}
type="select"
label={gettext("Assigned operator")}
options={assignment_options(@legal_assignees)}
/>
</div>
<div class="min-w-0"> <div class="min-w-0">
<.input <.input
field={form[:resolution_note]} field={form[:resolution_note]}
@ -307,14 +481,64 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
</button> </button>
</section> </section>
<section class="mt-12"> <section :if={@support_allowed}>
<h2 class="text-2xl font-bold">{gettext("General support and data requests")}</h2> <h2 class="text-2xl font-bold">{gettext("General support and data requests")}</h2>
<.form
for={@support_filter_form}
id="support-case-filters"
phx-change="filter-support"
class="mt-4 grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-4"
>
<.input
field={@support_filter_form[:search]}
type="search"
label={gettext("Search support cases")}
placeholder={gettext("Reference, email, subject, or details")}
phx-debounce="300"
/>
<.input
field={@support_filter_form[:status]}
type="select"
label={gettext("Status")}
options={[
{gettext("Any status"), ""},
{gettext("Open"), "open"},
{gettext("Reviewing"), "reviewing"},
{gettext("Waiting for requester"), "waiting_for_requester"},
{gettext("Resolved"), "resolved"},
{gettext("Closed"), "closed"}
]}
/>
<.input
field={@support_filter_form[:kind]}
type="select"
label={gettext("Request type")}
options={[
{gettext("Any type"), ""},
{gettext("Account access"), "account_access"},
{gettext("Technical issue"), "technical_issue"},
{gettext("Safety concern"), "safety_concern"},
{gettext("Moderation appeal"), "moderation_appeal"},
{gettext("Account deletion"), "account_deletion"},
{gettext("Data export"), "data_export"},
{gettext("Privacy request"), "privacy_request"},
{gettext("Other"), "other"}
]}
/>
<.input
field={@support_filter_form[:assigned_to_id]}
type="select"
label={gettext("Assignee")}
options={assignee_options(@support_assignees)}
/>
</.form>
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
<article :for={request <- @support_requests} class="rounded-2xl border border-base-300 p-5"> <article :for={request <- @support_requests} class="rounded-2xl border border-base-300 p-5">
<% form = <% form =
scoped_form( scoped_form(
%{ %{
"status" => to_string(request.status), "status" => to_string(request.status),
"assigned_to_id" => request.assigned_to_id || "",
"response" => "" "response" => ""
}, },
"support-#{request.id}" "support-#{request.id}"
@ -326,6 +550,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("contact unverified")} {gettext("contact unverified")}
</span> </span>
<span class="text-xs text-base-content/65">{support_kind(request.kind)}</span> <span class="text-xs text-base-content/65">{support_kind(request.kind)}</span>
<span class="badge badge-ghost">
{if request.assigned_to,
do:
gettext("Assigned: %{name}",
name: request.assigned_to.display_name || request.assigned_to.email
),
else: gettext("Unassigned")}
</span>
</div> </div>
<h3 class="mt-3 font-bold">{request.subject}</h3> <h3 class="mt-3 font-bold">{request.subject}</h3>
<p class="mt-1 text-xs text-base-content/65">{request.contact_email}</p> <p class="mt-1 text-xs text-base-content/65">{request.contact_email}</p>
@ -427,7 +659,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Audited update")} {gettext("Audited update")}
</span> </span>
</div> </div>
<div class="grid gap-5 p-4 xl:grid-cols-[15rem_minmax(0,1fr)] xl:items-start"> <div class="grid gap-5 p-4 xl:grid-cols-[15rem_15rem_minmax(0,1fr)] xl:items-start">
<div class="min-w-0"> <div class="min-w-0">
<.input <.input
field={form[:status]} field={form[:status]}
@ -446,6 +678,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Choose the state that will appear in the case history.")} {gettext("Choose the state that will appear in the case history.")}
</p> </p>
</div> </div>
<div class="min-w-0">
<.input
field={form[:assigned_to_id]}
type="select"
label={gettext("Assigned operator")}
options={assignment_options(@support_assignees)}
/>
</div>
<div class="min-w-0"> <div class="min-w-0">
<.input <.input
field={form[:response]} field={form[:response]}
@ -505,7 +745,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Load more")} {gettext("Load more")}
</button> </button>
</section> </section>
</Layouts.app> </AdminComponents.shell>
""" """
end end
end end

View File

@ -178,10 +178,35 @@ defmodule WhoNeedHelpWeb.Router do
live "/reports", ReportLive, :index live "/reports", ReportLive, :index
end end
live_session :staff_dashboard,
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :dashboard_view}}] do
live "/admin", AdminDashboardLive, :index
end
live_session :staff_users,
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :users_view}}] do
live "/admin/users", AdminUsersLive, :index
end
live_session :staff_audit,
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :audit_view}}] do
live "/admin/audit", AdminAuditLive, :index
end
live_session :moderation, live_session :moderation,
on_mount: [{WhoNeedHelpWeb.UserAuth, :ensure_moderator}] do on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :moderation_view}}] do
live "/moderation", ModerationLive, :index live "/moderation", ModerationLive, :index
end
live_session :analytics,
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :analytics_view}}] do
live "/analytics", ProductAnalyticsLive, :index live "/analytics", ProductAnalyticsLive, :index
end
live_session :support_operations,
on_mount: [
{WhoNeedHelpWeb.UserAuth, {:ensure_any_permission, [:support_view, :legal_view]}}
] do
live "/support/operations", SupportOperationsLive, :index live "/support/operations", SupportOperationsLive, :index
end end
end end

View File

@ -294,16 +294,57 @@ defmodule WhoNeedHelpWeb.UserAuth do
end end
def on_mount(:ensure_moderator, _params, session, socket) do def on_mount(:ensure_moderator, _params, session, socket) do
on_mount({:ensure_permission, :moderation_view}, %{}, session, socket)
end
def on_mount({:ensure_permission, permission}, _params, session, socket) do
case live_scope(session) do case live_scope(session) do
%Scope{user: %Accounts.User{} = user} = scope -> %Scope{user: %Accounts.User{} = user} = scope ->
if Accounts.moderator_authorized?(user) do if Accounts.authorized?(user, permission) do
set_live_locale(scope, session) set_live_locale(scope, session)
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)} {:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
else else
socket = socket =
socket socket
|> Phoenix.Component.assign(:current_scope, scope) |> Phoenix.Component.assign(:current_scope, scope)
|> Phoenix.LiveView.put_flash(:error, gettext("Moderator access is required.")) |> Phoenix.LiveView.put_flash(
:error,
gettext("You do not have access to that workspace.")
)
|> Phoenix.LiveView.redirect(to: ~p"/requests")
{:halt, socket}
end
_ ->
socket =
socket
|> Phoenix.Component.assign(:current_scope, nil)
|> Phoenix.LiveView.put_flash(
:error,
gettext("You must log in to access this page.")
)
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
{:halt, socket}
end
end
def on_mount({:ensure_any_permission, permissions}, _params, session, socket)
when is_list(permissions) do
case live_scope(session) do
%Scope{user: %Accounts.User{} = user} = scope ->
if Enum.any?(permissions, &Accounts.authorized?(user, &1)) do
set_live_locale(scope, session)
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
else
socket =
socket
|> Phoenix.Component.assign(:current_scope, scope)
|> Phoenix.LiveView.put_flash(
:error,
gettext("You do not have access to that workspace.")
)
|> Phoenix.LiveView.redirect(to: ~p"/requests") |> Phoenix.LiveView.redirect(to: ~p"/requests")
{:halt, socket} {:halt, socket}

View File

@ -0,0 +1,93 @@
defmodule WhoNeedHelp.Repo.Migrations.AddStaffRbacAndCaseAssignments do
use Ecto.Migration
def up do
create table(:staff_role_assignments, primary_key: false) do
add :id, :binary_id, primary_key: true
add :role, :string, null: false
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
add :assigned_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
timestamps(type: :utc_datetime)
end
create unique_index(:staff_role_assignments, [:user_id, :role])
create index(:staff_role_assignments, [:role, :user_id])
create index(:staff_role_assignments, [:assigned_by_id])
create index(:audit_events, [:inserted_at, :id])
create index(:audit_events, [:actor_id, :inserted_at, :id])
create index(:audit_events, [:action, :inserted_at, :id])
create constraint(:staff_role_assignments, :staff_role_assignments_known_role,
check: "role IN ('support', 'moderator', 'legal', 'analyst', 'admin')"
)
execute """
INSERT INTO staff_role_assignments (id, user_id, role, inserted_at, updated_at)
SELECT md5(users.id::text || ':' || users.role)::uuid,
users.id,
users.role,
timezone('UTC', now()),
timezone('UTC', now())
FROM users
WHERE users.role IN ('moderator', 'admin')
"""
alter table(:support_requests) do
add :assigned_to_id, references(:users, type: :binary_id, on_delete: :nilify_all)
end
create index(:support_requests, [:assigned_to_id, :status, :inserted_at, :id])
alter table(:content_removal_notices) do
add :assigned_to_id, references(:users, type: :binary_id, on_delete: :nilify_all)
end
create index(:content_removal_notices, [:assigned_to_id, :status, :inserted_at, :id])
alter table(:users) do
remove :role
end
end
def down do
drop index(:audit_events, [:action, :inserted_at, :id])
drop index(:audit_events, [:actor_id, :inserted_at, :id])
drop index(:audit_events, [:inserted_at, :id])
alter table(:users) do
add :role, :string, null: false, default: "user"
end
execute """
UPDATE users
SET role = CASE
WHEN EXISTS (
SELECT 1 FROM staff_role_assignments assignment
WHERE assignment.user_id = users.id AND assignment.role = 'admin'
) THEN 'admin'
WHEN EXISTS (
SELECT 1 FROM staff_role_assignments assignment
WHERE assignment.user_id = users.id
) THEN 'moderator'
ELSE 'user'
END
"""
drop index(:content_removal_notices, [:assigned_to_id, :status, :inserted_at, :id])
alter table(:content_removal_notices) do
remove :assigned_to_id
end
drop index(:support_requests, [:assigned_to_id, :status, :inserted_at, :id])
alter table(:support_requests) do
remove :assigned_to_id
end
drop table(:staff_role_assignments)
end
end

View File

@ -7,7 +7,9 @@ defmodule WhoNeedHelp.AccountsFixtures do
import Ecto.Query import Ecto.Query
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.StaffRoleAssignment
alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Repo
def unique_user_email, do: "user#{System.unique_integer()}@example.com" def unique_user_email, do: "user#{System.unique_integer()}@example.com"
def valid_user_password, do: "hello world!" def valid_user_password, do: "hello world!"
@ -43,6 +45,28 @@ defmodule WhoNeedHelp.AccountsFixtures do
user user
end end
def staff_user_fixture(roles, attrs \\ %{}) when is_list(roles) do
user = %{user_fixture(attrs) | authenticated_at: DateTime.utc_now(:second)}
Enum.each(roles, fn role ->
%StaffRoleAssignment{}
|> StaffRoleAssignment.changeset(%{user_id: user.id, role: role})
|> Repo.insert!()
end)
Accounts.preload_staff_roles(user)
end
def grant_staff_roles(user, roles) when is_list(roles) do
Enum.each(roles, fn role ->
%StaffRoleAssignment{}
|> StaffRoleAssignment.changeset(%{user_id: user.id, role: role})
|> Repo.insert!(on_conflict: :nothing, conflict_target: [:user_id, :role])
end)
Accounts.preload_staff_roles(user)
end
def user_scope_fixture do def user_scope_fixture do
user = user_fixture() user = user_fixture()
user_scope_fixture(user) user_scope_fixture(user)

View File

@ -11,7 +11,7 @@ defmodule WhoNeedHelp.AccountsTest do
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.Accounts.{SocialIdentity, User, UserToken} alias WhoNeedHelp.Accounts.{SocialIdentity, StaffPermissions, User, UserToken}
describe "get_user_by_email/1" do describe "get_user_by_email/1" do
test "does not return the user if the email does not exist" do test "does not return the user if the email does not exist" do
@ -103,11 +103,12 @@ defmodule WhoNeedHelp.AccountsTest do
|> set_password() |> set_password()
|> Ecto.Changeset.change( |> Ecto.Changeset.change(
bio: "A short public bio", bio: "A short public bio",
role: :admin,
moderation_note: "private moderator note" moderation_note: "private moderator note"
) )
|> Repo.update!() |> Repo.update!()
_user = grant_staff_roles(user, [:admin])
{:ok, identity} = {:ok, identity} =
Accounts.add_social_identity(user, %{ Accounts.add_social_identity(user, %{
"provider" => "telegram", "provider" => "telegram",
@ -125,7 +126,7 @@ defmodule WhoNeedHelp.AccountsTest do
assert identity_id == identity.id assert identity_id == identity.id
assert is_nil(public_user.email) assert is_nil(public_user.email)
assert is_nil(public_user.hashed_password) assert is_nil(public_user.hashed_password)
assert public_user.role == :user assert %Ecto.Association.NotLoaded{} = public_user.staff_role_assignments
assert is_nil(public_user.moderation_note) assert is_nil(public_user.moderation_note)
end end
@ -204,6 +205,40 @@ defmodule WhoNeedHelp.AccountsTest do
end end
end end
describe "staff roles and permissions" do
test "combines permissions from every assigned role and gives administrators all permissions" do
combined = staff_user_fixture([:support, :moderator])
assert Accounts.permission?(combined, :support_manage)
assert Accounts.permission?(combined, :moderation_manage)
assert Accounts.permission?(combined, :users_moderate)
refute Accounts.permission?(combined, :legal_manage)
refute Accounts.permission?(combined, :audit_view)
admin = staff_user_fixture([:admin])
assert Enum.all?(StaffPermissions.permissions(), &Accounts.permission?(admin, &1))
end
test "a restricted staff account keeps assignments but loses effective authorization" do
staff = staff_user_fixture([:support, :legal])
assert Accounts.authorized?(staff, :support_manage)
assert Accounts.authorized?(staff, :legal_manage)
restricted =
staff
|> User.moderation_changeset(%{
"moderation_status" => "restricted",
"moderation_note" => "Temporary staff access restriction"
})
|> Repo.update!()
assert Accounts.staff_roles(restricted) == [:legal, :support]
refute Accounts.authorized?(restricted, :support_manage)
refute Accounts.authorized?(restricted, :legal_manage)
end
end
describe "profile URL validation" do describe "profile URL validation" do
test "accepts only encrypted thank-you and social profile URLs" do test "accepts only encrypted thank-you and social profile URLs" do
user = user_fixture() user = user_fixture()

View File

@ -668,10 +668,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
end end
test "activity message reports expose only the linked group to audited moderators", context do test "activity message reports expose only the linked group to audited moderators", context do
moderator = moderator = staff_user_fixture([:moderator], display_name: "Activity moderator")
user_fixture(display_name: "Activity moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
attrs = Map.put(context.attrs, "capacity", 3) attrs = Map.put(context.attrs, "capacity", 3)
{:ok, activity} = Activities.create_activity(context.organizer_scope, attrs) {:ok, activity} = Activities.create_activity(context.organizer_scope, attrs)
@ -738,10 +735,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
end end
test "moderators can hide and restore a reported activity", context do test "moderators can hide and restore a reported activity", context do
moderator = moderator = staff_user_fixture([:moderator], display_name: "Activity moderator")
user_fixture(display_name: "Activity moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
moderator_scope = user_scope_fixture(moderator) moderator_scope = user_scope_fixture(moderator)
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs) {:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)

View File

@ -23,9 +23,8 @@ defmodule WhoNeedHelp.ProductAnalyticsTest do
assert ProductAnalytics.paginate(ordinary).entries == [] assert ProductAnalytics.paginate(ordinary).entries == []
moderator = moderator =
user_fixture() [:analyst]
|> Ecto.Changeset.change(role: :moderator) |> staff_user_fixture()
|> Repo.update!()
|> user_scope_fixture() |> user_scope_fixture()
assert [%DailyMetric{metric: "request.created", count: 2}] = assert [%DailyMetric{metric: "request.created", count: 2}] =

View File

@ -173,10 +173,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert request.contact_verified_at assert request.contact_verified_at
assert_email_sent() assert_email_sent()
moderator = moderator = staff_user_fixture([:support, :legal])
user_fixture()
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
assert_email_sent() assert_email_sent()
@ -206,6 +203,64 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
end) end)
end end
test "support and legal cases can be assigned only to active staff for the matching queue" do
requester = user_fixture()
requester_scope = user_scope_fixture(requester)
operator = staff_user_fixture([:support, :legal], display_name: "Queue operator")
support_assignee = staff_user_fixture([:support], display_name: "Support assignee")
legal_assignee = staff_user_fixture([:legal], display_name: "Legal assignee")
ordinary = user_fixture(display_name: "Ordinary account")
operator_scope = user_scope_fixture(operator)
assert {:ok, support_request} =
Support.create_request(requester_scope, %{
"kind" => "technical_issue",
"subject" => "Assign this support conversation",
"details" => "This verified support case should be assigned to a support operator."
})
assert {:error, :invalid_assignee} =
Support.moderate(operator_scope, support_request.id, %{
"status" => "reviewing",
"assigned_to_id" => ordinary.id
})
assert {:ok, assigned_support} =
Support.moderate(operator_scope, support_request.id, %{
"status" => "reviewing",
"assigned_to_id" => support_assignee.id
})
assert assigned_support.assigned_to_id == support_assignee.id
assert {:ok, notice} =
ContentRemoval.create_notice(requester_scope, :general, %{
"category" => "privacy_violation",
"submitter_name" => "Notice submitter",
"relationship" => "self",
"content_locations" => "https://example.test/requests/legal-assignment",
"explanation" =>
"This notice exercises assignment of a verified legal-review workflow.",
"electronic_signature" => "Notice submitter",
"good_faith" => "true",
"accurate_complete" => "true"
})
assert {:error, :invalid_assignee} =
ContentRemoval.moderate(operator_scope, notice.id, %{
"status" => "reviewing",
"assigned_to_id" => support_assignee.id
})
assert {:ok, assigned_notice} =
ContentRemoval.moderate(operator_scope, notice.id, %{
"status" => "reviewing",
"assigned_to_id" => legal_assignee.id
})
assert assigned_notice.assigned_to_id == legal_assignee.id
end
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)
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com") Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
@ -440,9 +495,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
end end
defp moderator_scope do defp moderator_scope do
user_fixture() staff_user_fixture([:support, :legal])
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
|> user_scope_fixture() |> user_scope_fixture()
end end
end end

View File

@ -3,7 +3,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Catalog, Help, Messaging, Release, Tracking, Trust} alias WhoNeedHelp.{Accounts, Catalog, Help, Messaging, Release, Tracking, Trust}
alias WhoNeedHelp.Help.Assignment alias WhoNeedHelp.Help.Assignment
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.Position alias WhoNeedHelp.Tracking.Position
@ -86,10 +86,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
outsider = user_fixture(display_name: "Report history outsider") outsider = user_fixture(display_name: "Report history outsider")
assert [] = Trust.list_reports_for_user(user_scope_fixture(outsider)) assert [] = Trust.list_reports_for_user(user_scope_fixture(outsider))
moderator = moderator = staff_user_fixture([:moderator], display_name: "Report history moderator")
user_fixture(display_name: "Report history moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
assert {:ok, resolved} = assert {:ok, resolved} =
Trust.moderate_report(user_scope_fixture(moderator), report.id, %{ Trust.moderate_report(user_scope_fixture(moderator), report.id, %{
@ -113,15 +110,9 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end end
test "forged moderation identifiers return not found without crashing", context do test "forged moderation identifiers return not found without crashing", context do
moderator = moderator = staff_user_fixture([:moderator], display_name: "Moderator")
user_fixture(display_name: "Moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
admin = admin = staff_user_fixture([:admin], display_name: "Administrator")
user_fixture(display_name: "Administrator")
|> Ecto.Changeset.change(role: :admin)
|> Repo.update!()
moderator_scope = user_scope_fixture(moderator) moderator_scope = user_scope_fixture(moderator)
admin_scope = user_scope_fixture(admin) admin_scope = user_scope_fixture(admin)
@ -136,7 +127,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
assert {:error, :not_found} = Trust.hide_activity(moderator_scope, id, "review") assert {:error, :not_found} = Trust.hide_activity(moderator_scope, id, "review")
assert {:error, :not_found} = Trust.restore_activity(moderator_scope, id) assert {:error, :not_found} = Trust.restore_activity(moderator_scope, id)
assert {:error, :not_found} = Trust.moderate_user(moderator_scope, id, %{}) assert {:error, :not_found} = Trust.moderate_user(moderator_scope, id, %{})
assert {:error, :not_found} = Trust.moderate_role(admin_scope, id, %{}) assert {:error, :not_found} = Trust.set_staff_roles(admin_scope, id, [])
assert {:error, :not_found} = Catalog.approve_proposal(moderator_scope, id, %{}) assert {:error, :not_found} = Catalog.approve_proposal(moderator_scope, id, %{})
assert {:error, :not_found} = Catalog.reject_proposal(moderator_scope, id, "review") assert {:error, :not_found} = Catalog.reject_proposal(moderator_scope, id, "review")
@ -154,10 +145,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end end
test "hiding a request requires a moderation reason", context do test "hiding a request requires a moderation reason", context do
moderator = moderator = staff_user_fixture([:moderator], display_name: "Reason moderator")
user_fixture(display_name: "Reason moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
{:ok, request} = Help.create_request(context.requester_scope, context.attrs) {:ok, request} = Help.create_request(context.requester_scope, context.attrs)
@ -169,10 +157,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end end
test "category moderation cannot cross help and activity taxonomies", context do test "category moderation cannot cross help and activity taxonomies", context do
moderator = moderator = staff_user_fixture([:moderator], display_name: "Category moderator")
user_fixture(display_name: "Category moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
moderator_scope = user_scope_fixture(moderator) moderator_scope = user_scope_fixture(moderator)
activity_category = Catalog.list_categories(:activity) |> List.first() activity_category = Catalog.list_categories(:activity) |> List.first()
@ -334,10 +319,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end end
test "reports expose only linked evidence to moderators and audit that access", context do test "reports expose only linked evidence to moderators and audit that access", context do
moderator = moderator = staff_user_fixture([:moderator], display_name: "Moderator")
user_fixture(display_name: "Moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
moderator_scope = user_scope_fixture(moderator) moderator_scope = user_scope_fixture(moderator)
{:ok, request} = Help.create_request(context.requester_scope, context.attrs) {:ok, request} = Help.create_request(context.requester_scope, context.attrs)
@ -669,55 +651,39 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end end
test "only an administrator can change roles and the last admin is protected", context do test "only an administrator can change roles and the last admin is protected", context do
moderator = moderator = staff_user_fixture([:moderator], display_name: "Moderator")
user_fixture(display_name: "Moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
admin = admin = staff_user_fixture([:admin], display_name: "Administrator")
user_fixture(display_name: "Administrator")
|> Ecto.Changeset.change(role: :admin)
|> Repo.update!()
assert {:error, :forbidden} = assert {:error, :forbidden} =
Trust.moderate_role(user_scope_fixture(moderator), context.helper.id, %{ Trust.set_staff_roles(user_scope_fixture(moderator), context.helper.id, ["moderator"])
"role" => "moderator"
})
assert {:ok, promoted} = assert {:ok, promoted} =
Trust.moderate_role(user_scope_fixture(admin), context.helper.id, %{ Trust.set_staff_roles(user_scope_fixture(admin), context.helper.id, [
"role" => "moderator" "support",
}) "moderator"
])
assert promoted.role == :moderator assert Accounts.loaded_staff_roles(promoted) == [:moderator, :support]
assert {:error, :last_admin} = assert {:error, :last_admin} =
Trust.moderate_role(user_scope_fixture(admin), admin.id, %{"role" => "user"}) Trust.set_staff_roles(user_scope_fixture(admin), admin.id, [])
assert Repo.exists?( assert Repo.exists?(
from event in AuditEvent, from event in AuditEvent,
where: where:
event.actor_id == ^admin.id and event.target_id == ^context.helper.id and event.actor_id == ^admin.id and event.target_id == ^context.helper.id and
event.action == "user.role_changed" event.action == "user.staff_roles_changed"
) )
end end
test "restricted moderators lose authorization and moderators cannot suspend administrators", test "restricted moderators lose authorization and moderators cannot suspend administrators",
context do context do
admin = admin = staff_user_fixture([:admin], display_name: "Administrator")
user_fixture(display_name: "Administrator")
|> Ecto.Changeset.change(role: :admin)
|> Repo.update!()
moderator = moderator = staff_user_fixture([:moderator], display_name: "Moderator")
user_fixture(display_name: "Moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
active_moderator = active_moderator = staff_user_fixture([:moderator], display_name: "Active moderator")
user_fixture(display_name: "Active moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
assert WhoNeedHelp.Accounts.moderator_authorized?(moderator) assert WhoNeedHelp.Accounts.moderator_authorized?(moderator)
@ -776,15 +742,9 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end end
test "moderators cannot restrict staff and restrictions require an internal note", context do test "moderators cannot restrict staff and restrictions require an internal note", context do
admin = admin = staff_user_fixture([:admin], display_name: "Staff administrator")
user_fixture(display_name: "Staff administrator")
|> Ecto.Changeset.change(role: :admin)
|> Repo.update!()
moderator = moderator = staff_user_fixture([:moderator], display_name: "Staff moderator")
user_fixture(display_name: "Staff moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
assert {:error, :forbidden} = assert {:error, :forbidden} =
Trust.moderate_user(user_scope_fixture(moderator), admin.id, %{ Trust.moderate_user(user_scope_fixture(moderator), admin.id, %{
@ -813,7 +773,8 @@ defmodule WhoNeedHelp.TrustSafetyTest do
test "the first administrator bootstrap is one-time and audited", context do test "the first administrator bootstrap is one-time and audited", context do
assert {:ok, admin} = Release.bootstrap_admin(context.helper.email) assert {:ok, admin} = Release.bootstrap_admin(context.helper.email)
assert admin.id == context.helper.id assert admin.id == context.helper.id
assert admin.role == :admin assert admin.roles == [:admin]
assert Accounts.staff_roles(context.helper) == [:admin]
assert {:error, :admin_already_exists} = assert {:error, :admin_already_exists} =
Release.bootstrap_admin(context.requester.email) Release.bootstrap_admin(context.requester.email)

View File

@ -216,10 +216,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
"details" => "This request verifies the private support conversation flow." "details" => "This request verifies the private support conversation flow."
}) })
moderator = moderator = staff_user_fixture([:support])
user_fixture()
|> Ecto.Changeset.change(role: :moderator)
|> WhoNeedHelp.Repo.update!()
{:ok, _resolved} = {:ok, _resolved} =
WhoNeedHelp.Support.moderate(user_scope_fixture(moderator), request.id, %{ WhoNeedHelp.Support.moderate(user_scope_fixture(moderator), request.id, %{
@ -269,10 +266,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
"details" => "Both connected support views must refresh without a browser reload." "details" => "Both connected support views must refresh without a browser reload."
}) })
moderator = moderator = staff_user_fixture([:support])
user_fixture()
|> Ecto.Changeset.change(role: :moderator)
|> WhoNeedHelp.Repo.update!()
{:ok, requester_view, _html} = live(conn, ~p"/support/cases/#{request.id}") {:ok, requester_view, _html} = live(conn, ~p"/support/cases/#{request.id}")
@ -313,10 +307,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
end end
test "renders separate support and removal queues for a moderator", %{conn: conn} do test "renders separate support and removal queues for a moderator", %{conn: conn} do
moderator = moderator = staff_user_fixture([:support, :legal])
user_fixture()
|> Ecto.Changeset.change(role: :moderator)
|> WhoNeedHelp.Repo.update!()
{:ok, support_request} = {:ok, support_request} =
WhoNeedHelp.Support.create_request(nil, %{ WhoNeedHelp.Support.create_request(nil, %{

View File

@ -0,0 +1,114 @@
defmodule WhoNeedHelpWeb.AdminLiveTest do
use WhoNeedHelpWeb.ConnCase, async: false
import Ecto.Query
import Phoenix.LiveViewTest
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Accounts, Repo}
alias WhoNeedHelp.Trust.AuditEvent
test "staff routes expose only the workspaces allowed by the union of assigned roles", %{
conn: conn
} do
regular = user_fixture()
assert {:error, {:redirect, %{to: "/requests"}}} =
conn |> log_in_user(regular) |> live(~p"/admin")
support = staff_user_fixture([:support])
support_conn = log_in_user(conn, support)
assert {:ok, _view, html} = live(support_conn, ~p"/admin")
assert html =~ ">Support<"
refute html =~ "Support and legal"
refute html =~ "Users and roles"
refute html =~ "Trust and safety"
assert {:ok, _view, support_html} = live(support_conn, ~p"/support/operations")
assert support_html =~ "Support operations"
refute support_html =~ "Content removal and TAKE IT DOWN"
assert {:error, {:redirect, %{to: "/requests"}}} =
live(support_conn, ~p"/admin/users")
support_moderator = staff_user_fixture([:support, :moderator])
combined_conn = log_in_user(conn, support_moderator)
assert {:ok, _view, html} = live(combined_conn, ~p"/admin")
assert html =~ ">Support<"
refute html =~ "Support and legal"
assert html =~ "Users and roles"
assert html =~ "Trust and safety"
assert {:ok, _view, _html} = live(combined_conn, ~p"/support/operations")
assert {:ok, _view, _html} = live(combined_conn, ~p"/moderation")
assert {:ok, _view, _html} = live(combined_conn, ~p"/admin/users")
assert {:error, {:redirect, %{to: "/requests"}}} =
live(combined_conn, ~p"/admin/audit")
end
test "administrator can combine roles, suspend users, and inspect audited actions", %{
conn: conn
} do
admin = staff_user_fixture([:admin], display_name: "Operations administrator")
target = user_fixture(display_name: "Account under review")
conn = log_in_user(conn, admin)
{:ok, users_view, html} = live(conn, ~p"/admin/users")
assert html =~ "Users and staff access"
assert has_element?(users_view, "#staff-roles-#{target.id}")
users_view
|> form("#staff-roles-#{target.id}", %{
"staff" => %{"roles" => ["support", "moderator"]}
})
|> render_submit()
updated = target |> Repo.reload!() |> Accounts.preload_staff_roles()
assert Accounts.loaded_staff_roles(updated) == [:moderator, :support]
users_view
|> form("#moderate-user-#{target.id}", %{
"moderation" => %{
"moderation_status" => "suspended",
"moderation_note" => "Confirmed administrative test restriction"
}
})
|> render_submit()
assert Repo.reload!(target).moderation_status == :suspended
assert Repo.exists?(
from event in AuditEvent,
where:
event.actor_id == ^admin.id and event.target_id == ^target.id and
event.action == "user.staff_roles_changed"
)
assert Repo.exists?(
from event in AuditEvent,
where:
event.actor_id == ^admin.id and event.target_id == ^target.id and
event.action == "user.moderated"
)
{:ok, _audit_view, audit_html} = live(conn, ~p"/admin/audit")
assert audit_html =~ "user.staff_roles_changed"
assert audit_html =~ "user.moderated"
end
test "administrator UI protects the final active administrator", %{conn: conn} do
admin = staff_user_fixture([:admin], display_name: "Only administrator")
conn = log_in_user(conn, admin)
{:ok, view, _html} = live(conn, ~p"/admin/users")
html =
view
|> form("#staff-roles-#{admin.id}", %{"staff" => %{"roles" => [""]}})
|> render_submit()
assert html =~ "last active administrator"
assert Accounts.staff_roles(admin) == [:admin]
end
end

View File

@ -33,10 +33,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"details" => "Review this request and keep the report private." "details" => "Review this request and keep the report private."
}) })
moderator = moderator = staff_user_fixture([:moderator], display_name: "Report moderator")
user_fixture(display_name: "Report moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
{:ok, _report} = {:ok, _report} =
Trust.moderate_report(user_scope_fixture(moderator), report.id, %{ Trust.moderate_report(user_scope_fixture(moderator), report.id, %{
@ -1502,11 +1499,12 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation") assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation")
end end
test "moderator dashboard renders reports, signals, proposals, and accounts", %{ test "moderator workspace renders reports, signals, and proposals without account management",
%{
conn: conn, conn: conn,
user: user user: user
} do } do
user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() grant_staff_roles(user, [:moderator])
category = Catalog.seed_defaults() category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Requester") requester = user_fixture(display_name: "Requester")
helper = user_fixture(display_name: "Helper") helper = user_fixture(display_name: "Helper")
@ -1538,11 +1536,10 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Reports" assert html =~ "Reports"
assert html =~ "Open abuse signals" assert html =~ "Open abuse signals"
assert html =~ "Category proposals" assert html =~ "Category proposals"
assert html =~ "Accounts" refute html =~ "Users & roles"
assert html =~ "Please review the matched conversation." assert html =~ "Please review the matched conversation."
assert html =~ "Bicycle repair" assert html =~ "Bicycle repair"
refute has_element?(view, "#moderation-user-status-#{user.id}") refute has_element?(view, "#moderation-user-status-#{user.id}")
assert html =~ "Administrator access is required to moderate staff accounts."
assert has_element?( assert has_element?(
view, view,
@ -1562,7 +1559,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
user: user, user: user,
scope: participant_scope scope: participant_scope
} do } do
user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() grant_staff_roles(user, [:moderator])
organizer = user_fixture(display_name: "Activity organizer") organizer = user_fixture(display_name: "Activity organizer")
Catalog.seed_defaults() Catalog.seed_defaults()
activity_category = Catalog.list_categories(:activity) |> List.first() activity_category = Catalog.list_categories(:activity) |> List.first()
@ -1615,7 +1612,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
conn: conn, conn: conn,
user: user user: user
} do } do
user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() grant_staff_roles(user, [:moderator])
subject = user_fixture(display_name: "Signal subject") subject = user_fixture(display_name: "Signal subject")
for sequence <- 1..25 do for sequence <- 1..25 do