diff --git a/README.md b/README.md
index d1e382b..c7d3209 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,10 @@ local Codex CLI authenticated with their ChatGPT subscription.
disables rejected device registrations.
- Bidirectional discovery blocks, scoped reports, account/request/category
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
appeals, account deletion/data requests, a URL-only TAKE IT DOWN form,
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.
-After bootstrap, an administrator can manage roles in `/moderation`; the last
-administrator cannot demote themselves. For kind, append `kind`:
+After bootstrap, an administrator can manage multiple staff roles in
+`/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
./scripts/bootstrap-admin.sh you@example.com --confirm kind
diff --git a/docs/staff-operations.md b/docs/staff-operations.md
new file mode 100644
index 0000000..2189d85
--- /dev/null
+++ b/docs/staff-operations.md
@@ -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.
diff --git a/docs/support-and-content-removal.md b/docs/support-and-content-removal.md
index 92aec8f..4a84c3a 100644
--- a/docs/support-and-content-removal.md
+++ b/docs/support-and-content-removal.md
@@ -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
separate regime value in the removal queue.
-The operator workspace is `/support/operations`. It is protected by the same
-database-checked moderator or administrator authorization as the existing
-moderation workspace. Every creation and decision records an audit event.
+The operator workspace is `/support/operations`. Support and legal access are
+separate database-checked permissions that can be combined on one account;
+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
diff --git a/docs/trust-safety.md b/docs/trust-safety.md
index e80ec32..d9402a1 100644
--- a/docs/trust-safety.md
+++ b/docs/trust-safety.md
@@ -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
the evidence access audit records the Activity and message count.
-Report, request, signal, category, role, and account moderation are role
-protected. The local Codex
+Report, request, signal, category, role, and account moderation are protected by
+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
private messages, email addresses, OAuth tokens, exact coordinates, or raw
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.
Suspension invalidates that account's login sessions. The first administrator
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
notice, prohibited-items policy, and data-retention policy before public launch.
diff --git a/lib/mix/tasks/wnh.staging_full_e2e.ex b/lib/mix/tasks/wnh.staging_full_e2e.ex
index 876c0d3..b3191a7 100644
--- a/lib/mix/tasks/wnh.staging_full_e2e.ex
+++ b/lib/mix/tasks/wnh.staging_full_e2e.ex
@@ -4,7 +4,7 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
import Ecto.Query
alias Oban.Job
- alias WhoNeedHelp.Accounts.{SocialIdentity, User}
+ alias WhoNeedHelp.Accounts.{SocialIdentity, StaffRoleAssignment, User}
alias WhoNeedHelp.Activities.{Activity, Participant}
alias WhoNeedHelp.Activities.Message, as: ActivityMessage
alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote}
@@ -94,17 +94,20 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
{:ok, users} =
Repo.transaction(fn ->
Map.new(@precreated_roles, fn role ->
- user_role = if role == "admin", do: :admin, else: :user
-
user =
insert_user!(
context.emails[role],
display_name(role),
password_hash,
- user_role,
now
)
+ if role == "admin" do
+ %StaffRoleAssignment{}
+ |> StaffRoleAssignment.changeset(%{user_id: user.id, role: :admin})
+ |> Repo.insert!()
+ end
+
{role, %{"id" => user.id, "email" => user.email}}
end)
end)
@@ -500,7 +503,7 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
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.registration_changeset(%{
"email" => email,
@@ -510,7 +513,6 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
})
|> Ecto.Changeset.put_change(:hashed_password, password_hash)
|> Ecto.Changeset.put_change(:confirmed_at, now)
- |> Ecto.Changeset.put_change(:role, role)
|> Repo.insert!()
end
diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex
index a5f80b0..0cf3aee 100644
--- a/lib/who_need_help/accounts.ex
+++ b/lib/who_need_help/accounts.ex
@@ -11,6 +11,8 @@ defmodule WhoNeedHelp.Accounts do
AuthIdentity,
Scope,
SocialIdentity,
+ StaffPermissions,
+ StaffRoleAssignment,
User,
UserNotifier,
UserToken
@@ -154,46 +156,85 @@ defmodule WhoNeedHelp.Accounts do
)
end
- def moderator?(%User{role: role}), do: role in [:moderator, :admin]
- def moderator?(_user), do: false
+ def staff_roles(%User{id: id}) do
+ 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})
- when role in [:moderator, :admin],
- do:
+ def loaded_staff_roles(%User{staff_role_assignments: %Ecto.Association.NotLoaded{}}), 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?(
+ from assignment in StaffRoleAssignment,
+ join: user in User,
+ on: user.id == assignment.user_id,
+ where:
+ assignment.user_id == ^id and assignment.role in ^allowed_roles and
+ user.moderation_status == :active
+ )
+ end
+
+ def authorized?(_user, _permission), do: false
+
+ def authorized_user_id?(user_id, permission) do
+ with {:ok, user_id} <- cast_id(user_id) do
+ allowed_roles = StaffPermissions.roles_for_permission(permission)
+
+ allowed_roles != [] and
Repo.exists?(
- from user in User,
+ from assignment in StaffRoleAssignment,
+ join: user in User,
+ on: user.id == assignment.user_id,
where:
- user.id == ^id and user.role in [:moderator, :admin] and
+ assignment.user_id == ^user_id and assignment.role in ^allowed_roles and
user.moderation_status == :active
)
+ else
+ _ -> false
+ end
+ end
- def moderator_authorized?(_user), do: false
-
- def admin?(%User{role: :admin}), do: true
- def admin?(_user), do: false
-
- def admin_authorized?(%User{id: id, role: :admin}),
- do:
- Repo.exists?(
- from user in User,
- where: user.id == ^id and user.role == :admin and user.moderation_status == :active
- )
-
- 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
paginate_users_for_moderation(%Scope{user: user}).entries
end
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)
cursor = Pagination.cursor(options)
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)
|> order_by([user], desc: user.inserted_at, desc: user.id)
|> limit(^(limit + 1))
+ |> preload(:staff_role_assignments)
|> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
else
@@ -203,7 +244,7 @@ defmodule WhoNeedHelp.Accounts do
def moderate_user(%User{} = moderator, user_id, attrs) do
with {:ok, user_id} <- cast_id(user_id),
- true <- moderator_authorized?(moderator) do
+ true <- authorized?(moderator, :users_moderate) do
Repo.transact(fn ->
active_admins = lock_active_admins()
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]
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}
user.id == moderator.id and
requested_status in [:restricted, :suspended, "restricted", "suspended"] ->
{: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 ->
{:error, :last_admin}
@@ -252,21 +293,54 @@ defmodule WhoNeedHelp.Accounts do
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),
- true <- admin_authorized?(admin) do
+ true <- admin_authorized?(admin),
+ true <- sudo_mode?(admin, -10),
+ {:ok, roles} <- normalize_staff_roles(roles) do
Repo.transact(fn ->
active_admins = lock_active_admins()
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
if user do
- requested_role = attrs["role"] || attrs[:role]
+ current_roles = staff_roles(user)
- if user.role == :admin and user.moderation_status == :active and
- requested_role not in [:admin, "admin"] and length(active_admins) == 1 do
+ if :admin in current_roles and user.moderation_status == :active and
+ :admin not in roles and length(active_admins) == 1 do
{:error, :last_admin}
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
else
{:error, :not_found}
@@ -275,17 +349,76 @@ defmodule WhoNeedHelp.Accounts do
else
false -> {:error, :forbidden}
{:error, :not_found} = error -> error
+ {:error, :invalid_roles} = error -> error
end
end
+ def set_staff_roles(_admin, _user_id, _roles), do: {:error, :invalid_roles}
+
defp lock_active_admins do
- User
- |> where([user], user.role == :admin and user.moderation_status == :active)
- |> order_by([user], asc: user.id)
+ active_user_ids =
+ from user in User,
+ 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")
|> Repo.all()
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
@doc """
@@ -809,7 +942,11 @@ defmodule WhoNeedHelp.Accounts do
"""
def get_user_by_session_token(token) do
{: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
@doc """
@@ -967,6 +1104,49 @@ defmodule WhoNeedHelp.Accounts do
)
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
case Ecto.UUID.cast(value) do
{:ok, id} -> {:ok, id}
diff --git a/lib/who_need_help/accounts/data_export.ex b/lib/who_need_help/accounts/data_export.ex
index 67e5051..50e4b85 100644
--- a/lib/who_need_help/accounts/data_export.ex
+++ b/lib/who_need_help/accounts/data_export.ex
@@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Accounts.DataExport do
import Ecto.Query
+ alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.{AuthIdentity, Scope, SocialIdentity, User}
alias WhoNeedHelp.Activities.{Activity, Message, Participant}
alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote}
@@ -31,7 +32,8 @@ defmodule WhoNeedHelp.Accounts.DataExport do
"version" => @version,
"exported_at" => exported_at,
"account" =>
- record(user, [
+ user
+ |> record([
:id,
:email,
:display_name,
@@ -39,14 +41,14 @@ defmodule WhoNeedHelp.Accounts.DataExport do
:locale,
:location_visibility,
:direct_message_policy,
- :role,
:moderation_status,
:tip_url,
:confirmed_at,
:accepted_terms_at,
:inserted_at,
:updated_at
- ]),
+ ])
+ |> Map.put(:staff_roles, Accounts.staff_roles(user)),
"authentication_identities" =>
owned(AuthIdentity, :user_id, user_id, [
:id,
diff --git a/lib/who_need_help/accounts/data_lifecycle.ex b/lib/who_need_help/accounts/data_lifecycle.ex
index a2f5c06..1086c46 100644
--- a/lib/who_need_help/accounts/data_lifecycle.ex
+++ b/lib/who_need_help/accounts/data_lifecycle.ex
@@ -10,6 +10,7 @@ defmodule WhoNeedHelp.Accounts.DataLifecycle do
import Ecto.Query
+ alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.Activities.{Activity, Participant}
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
@@ -45,7 +46,7 @@ defmodule WhoNeedHelp.Accounts.DataLifecycle do
case_id: request.id,
reference: request.reference,
account_id: user.id,
- account_role: user.role,
+ account_staff_roles: Accounts.staff_roles(user),
contact_verified: true,
assessed_by: moderator.id,
assessed_at: DateTime.utc_now(:second),
diff --git a/lib/who_need_help/accounts/staff_permissions.ex b/lib/who_need_help/accounts/staff_permissions.ex
new file mode 100644
index 0000000..f8d4e81
--- /dev/null
+++ b/lib/who_need_help/accounts/staff_permissions.ex
@@ -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
diff --git a/lib/who_need_help/accounts/staff_role_assignment.ex b/lib/who_need_help/accounts/staff_role_assignment.ex
new file mode 100644
index 0000000..b23b4ce
--- /dev/null
+++ b/lib/who_need_help/accounts/staff_role_assignment.ex
@@ -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
diff --git a/lib/who_need_help/accounts/user.ex b/lib/who_need_help/accounts/user.ex
index 76e46af..1d91719 100644
--- a/lib/who_need_help/accounts/user.ex
+++ b/lib/who_need_help/accounts/user.ex
@@ -24,8 +24,6 @@ defmodule WhoNeedHelp.Accounts.User do
values: [:everyone, :verified_accounts, :completed_help_users, :nobody],
default: :verified_accounts
- field :role, Ecto.Enum, values: [:user, :moderator, :admin], default: :user
-
field :moderation_status, Ecto.Enum,
values: [:active, :restricted, :suspended],
default: :active
@@ -40,6 +38,9 @@ defmodule WhoNeedHelp.Accounts.User do
has_many :nearby_subscriptions, WhoNeedHelp.Notifications.NearbySubscription
has_many :push_devices, WhoNeedHelp.Notifications.PushDevice
+ has_many :staff_role_assignments, WhoNeedHelp.Accounts.StaffRoleAssignment,
+ preload_order: [asc: :role]
+
timestamps(type: :utc_datetime)
end
@@ -115,12 +116,6 @@ defmodule WhoNeedHelp.Accounts.User do
end
end
- def role_changeset(user, attrs) do
- user
- |> cast(attrs, [:role])
- |> validate_required([:role])
- end
-
defp validate_url(changeset, field) do
validate_change(changeset, field, fn ^field, value ->
case URI.parse(value) do
diff --git a/lib/who_need_help/admin.ex b/lib/who_need_help/admin.ex
new file mode 100644
index 0000000..6437037
--- /dev/null
+++ b/lib/who_need_help/admin.ex
@@ -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
diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex
index 0acc341..579b6ca 100644
--- a/lib/who_need_help/catalog.ex
+++ b/lib/who_need_help/catalog.ex
@@ -185,7 +185,7 @@ defmodule WhoNeedHelp.Catalog do
end
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)
cursor = Pagination.cursor(options)
@@ -204,7 +204,7 @@ defmodule WhoNeedHelp.Catalog do
def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do
with {:ok, proposal_id} <- cast_id(proposal_id),
- true <- Accounts.moderator_authorized?(moderator) do
+ true <- Accounts.authorized?(moderator, :categories_manage) do
Repo.transact(fn ->
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
with {:ok, proposal_id} <- cast_id(proposal_id),
- true <- Accounts.moderator_authorized?(moderator) do
+ true <- Accounts.authorized?(moderator, :categories_manage) do
Repo.transact(fn ->
proposal = locked_proposal(proposal_id)
diff --git a/lib/who_need_help/content_removal.ex b/lib/who_need_help/content_removal.ex
index f07c462..9049bc1 100644
--- a/lib/who_need_help/content_removal.ex
+++ b/lib/who_need_help/content_removal.ex
@@ -106,16 +106,19 @@ defmodule WhoNeedHelp.ContentRemoval do
end
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)
cursor = Pagination.cursor(options)
Notice
|> 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)
|> order_by([notice], desc: notice.inserted_at, desc: notice.id)
|> limit(^(limit + 1))
- |> preload([:requester, :reviewed_by])
+ |> preload([:requester, :reviewed_by, :assigned_to])
|> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
else
@@ -125,41 +128,42 @@ defmodule WhoNeedHelp.ContentRemoval do
def moderate(%Scope{user: moderator}, id, attrs) do
with {:ok, id} <- Ecto.UUID.cast(id),
- true <- Accounts.moderator_authorized?(moderator) do
- attrs =
- attrs
- |> normalize_keys()
- |> Map.merge(%{
- "reviewed_at" => DateTime.utc_now(:second),
- "reviewed_by_id" => moderator.id
- })
+ true <- Accounts.authorized?(moderator, :legal_manage) do
+ with {:ok, attrs} <- normalize_assignment(normalize_keys(attrs), :legal_manage) do
+ attrs =
+ Map.merge(attrs, %{
+ "reviewed_at" => DateTime.utc_now(:second),
+ "reviewed_by_id" => moderator.id
+ })
- Repo.transact(fn ->
- notice =
- Notice
- |> where([notice], notice.id == ^id)
- |> lock("FOR UPDATE")
- |> Repo.one()
+ Repo.transact(fn ->
+ notice =
+ Notice
+ |> where([notice], notice.id == ^id)
+ |> lock("FOR UPDATE")
+ |> Repo.one()
- if notice do
- with {:ok, notice} <- notice |> Notice.moderation_changeset(attrs) |> Repo.update(),
- {:ok, _audit} <-
- Trust.audit(
- moderator.id,
- "content_removal_notice.moderated",
- "content_removal_notice",
- notice.id,
- %{
- "status" => to_string(notice.status)
- }
- ) do
- {:ok, notice}
+ if notice do
+ with {:ok, notice} <- notice |> Notice.moderation_changeset(attrs) |> Repo.update(),
+ {:ok, _audit} <-
+ Trust.audit(
+ moderator.id,
+ "content_removal_notice.moderated",
+ "content_removal_notice",
+ notice.id,
+ %{
+ "status" => to_string(notice.status),
+ "assigned_to_id" => notice.assigned_to_id
+ }
+ ) do
+ {:ok, Repo.preload(notice, :assigned_to, force: true)}
+ end
+ else
+ {:error, :not_found}
end
- else
- {:error, :not_found}
- end
- end)
- |> notify_decision()
+ end)
+ |> notify_decision()
+ end
else
false -> {:error, :forbidden}
_ -> {:error, :not_found}
@@ -230,8 +234,54 @@ defmodule WhoNeedHelp.ContentRemoval do
end
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_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
Map.put(attrs, "contact_email", email)
end
diff --git a/lib/who_need_help/content_removal/notice.ex b/lib/who_need_help/content_removal/notice.ex
index 558476f..ee06d4b 100644
--- a/lib/who_need_help/content_removal/notice.ex
+++ b/lib/who_need_help/content_removal/notice.ex
@@ -55,6 +55,7 @@ defmodule WhoNeedHelp.ContentRemoval.Notice do
field :decision_sent_at, :utc_datetime
belongs_to :requester, WhoNeedHelp.Accounts.User
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
+ belongs_to :assigned_to, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime)
end
@@ -103,7 +104,14 @@ defmodule WhoNeedHelp.ContentRemoval.Notice do
def moderation_changeset(notice, attrs) do
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_length(:resolution_note, max: 10_000)
end
diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex
index 3ffc5c5..7d47871 100644
--- a/lib/who_need_help/help.ex
+++ b/lib/who_need_help/help.ex
@@ -342,7 +342,7 @@ defmodule WhoNeedHelp.Help do
request.requester_id == user.id ->
{:ok, request}
- Accounts.moderator_authorized?(user) ->
+ Accounts.authorized?(user, :moderation_view) ->
{:ok, request}
not is_nil(request.hidden_at) ->
diff --git a/lib/who_need_help/product_analytics.ex b/lib/who_need_help/product_analytics.ex
index c03fc08..7af0993 100644
--- a/lib/who_need_help/product_analytics.ex
+++ b/lib/who_need_help/product_analytics.ex
@@ -71,7 +71,7 @@ defmodule WhoNeedHelp.ProductAnalytics do
def increment_for_user(_user, _metric, _dimension), do: {:error, :invalid_user}
def paginate(%Scope{user: user}, options \\ []) do
- if Accounts.moderator_authorized?(user) do
+ if Accounts.authorized?(user, :analytics_view) do
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
diff --git a/lib/who_need_help/release.ex b/lib/who_need_help/release.ex
index f007609..9942b45 100644
--- a/lib/who_need_help/release.ex
+++ b/lib/who_need_help/release.ex
@@ -33,7 +33,9 @@ defmodule WhoNeedHelp.Release do
admin_count =
WhoNeedHelp.Repo.aggregate(
- from(user in WhoNeedHelp.Accounts.User, where: user.role == :admin),
+ from(assignment in WhoNeedHelp.Accounts.StaffRoleAssignment,
+ where: assignment.role == :admin
+ ),
:count
)
@@ -42,13 +44,16 @@ defmodule WhoNeedHelp.Release do
{:error, :admin_already_exists}
user = WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) ->
- with {:ok, user} <-
- user
- |> WhoNeedHelp.Accounts.User.role_changeset(%{role: :admin})
- |> WhoNeedHelp.Repo.update(),
+ with {:ok, _assignment} <-
+ %WhoNeedHelp.Accounts.StaffRoleAssignment{}
+ |> WhoNeedHelp.Accounts.StaffRoleAssignment.changeset(%{
+ user_id: user.id,
+ role: :admin
+ })
+ |> WhoNeedHelp.Repo.insert(),
{:ok, _audit} <-
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
true ->
diff --git a/lib/who_need_help/support.ex b/lib/who_need_help/support.ex
index 2cc0577..38c6160 100644
--- a/lib/who_need_help/support.ex
+++ b/lib/who_need_help/support.ex
@@ -141,19 +141,23 @@ defmodule WhoNeedHelp.Support do
end
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)
cursor = Pagination.cursor(options)
SupportRequest
|> where([request], not is_nil(request.contact_verified_at))
|> 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)
|> order_by([request], desc: request.inserted_at, desc: request.id)
|> limit(^(limit + 1))
|> preload([
:requester,
:reviewed_by,
+ :assigned_to,
conversation_messages: :sender,
status_events: :actor
])
@@ -166,62 +170,65 @@ defmodule WhoNeedHelp.Support do
def moderate(%Scope{user: moderator}, id, attrs) do
with {:ok, id} <- Ecto.UUID.cast(id),
- true <- Accounts.moderator_authorized?(moderator) do
+ true <- Accounts.authorized?(moderator, :support_manage) do
attrs = normalize_keys(attrs)
response = normalize_message(attrs["response"] || attrs["resolution_note"])
- attrs =
- attrs
- |> Map.delete("response")
- |> maybe_put_resolution(response)
- |> Map.merge(%{
- "reviewed_at" => DateTime.utc_now(:second),
- "reviewed_by_id" => moderator.id
- })
+ with {:ok, attrs} <- normalize_assignment(attrs, :support_manage) do
+ attrs =
+ attrs
+ |> Map.delete("response")
+ |> maybe_put_resolution(response)
+ |> Map.merge(%{
+ "reviewed_at" => DateTime.utc_now(:second),
+ "reviewed_by_id" => moderator.id
+ })
- Repo.transact(fn ->
- request =
- SupportRequest
- |> where([request], request.id == ^id and not is_nil(request.contact_verified_at))
- |> lock("FOR UPDATE")
- |> Repo.one()
+ Repo.transact(fn ->
+ request =
+ SupportRequest
+ |> where([request], request.id == ^id and not is_nil(request.contact_verified_at))
+ |> lock("FOR UPDATE")
+ |> Repo.one()
- if request do
- previous_status = request.status
+ if request do
+ previous_status = request.status
- response_to_record =
- if response && response != request.resolution_note, do: response
+ response_to_record =
+ if response && response != request.resolution_note, do: response
- with {:ok, request} <-
- request |> SupportRequest.moderation_changeset(attrs) |> Repo.update(),
- {:ok, _event} <-
- maybe_record_status_event(
- request,
- previous_status,
- request.status,
- moderator.id,
- :staff
- ),
- {:ok, _message} <-
- maybe_record_message(request, moderator.id, :staff, response_to_record),
- {:ok, _audit} <-
- Trust.audit(
- moderator.id,
- "support_request.moderated",
- "support_request",
- request.id,
- %{
- "status" => to_string(request.status)
- }
- ) do
- {:ok, preload_conversation(request)}
+ with {:ok, request} <-
+ request |> SupportRequest.moderation_changeset(attrs) |> Repo.update(),
+ {:ok, _event} <-
+ maybe_record_status_event(
+ request,
+ previous_status,
+ request.status,
+ moderator.id,
+ :staff
+ ),
+ {:ok, _message} <-
+ maybe_record_message(request, moderator.id, :staff, response_to_record),
+ {:ok, _audit} <-
+ Trust.audit(
+ moderator.id,
+ "support_request.moderated",
+ "support_request",
+ request.id,
+ %{
+ "status" => to_string(request.status),
+ "assigned_to_id" => request.assigned_to_id
+ }
+ ) do
+ {:ok, preload_conversation(request)}
+ end
+ else
+ {:error, :not_found}
end
- else
- {:error, :not_found}
- end
- end)
- |> notify_decision()
- |> broadcast_request_update()
+ end)
+ |> notify_decision()
+ |> broadcast_request_update()
+ end
else
false -> {:error, :forbidden}
_ -> {:error, :not_found}
@@ -343,7 +350,7 @@ defmodule WhoNeedHelp.Support do
end
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),
%SupportRequest{} = request <-
Repo.one(
@@ -487,7 +494,7 @@ defmodule WhoNeedHelp.Support do
defp preload_conversation(%SupportRequest{} = request) do
Repo.preload(
request,
- [conversation_messages: :sender, status_events: :actor],
+ [:assigned_to, conversation_messages: :sender, status_events: :actor],
force: true
)
end
@@ -565,8 +572,54 @@ defmodule WhoNeedHelp.Support do
end
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_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
[{:support_request, rate_scope(user, contact_email)}]
|> maybe_add_client_rate_scope(client_scope)
diff --git a/lib/who_need_help/support/support_request.ex b/lib/who_need_help/support/support_request.ex
index 88b06ba..27dd718 100644
--- a/lib/who_need_help/support/support_request.ex
+++ b/lib/who_need_help/support/support_request.ex
@@ -41,6 +41,7 @@ defmodule WhoNeedHelp.Support.SupportRequest do
field :response_sent_at, :utc_datetime
belongs_to :requester, WhoNeedHelp.Accounts.User
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
+ belongs_to :assigned_to, WhoNeedHelp.Accounts.User
has_many :conversation_messages, WhoNeedHelp.Support.ConversationMessage,
preload_order: [asc: :inserted_at, asc: :id]
@@ -68,7 +69,14 @@ defmodule WhoNeedHelp.Support.SupportRequest do
def moderation_changeset(request, attrs) do
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_length(:resolution_note, max: 5_000)
end
diff --git a/lib/who_need_help/trust.ex b/lib/who_need_help/trust.ex
index 366df34..8cee4e0 100644
--- a/lib/who_need_help/trust.ex
+++ b/lib/who_need_help/trust.ex
@@ -332,7 +332,7 @@ defmodule WhoNeedHelp.Trust do
end
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)
cursor = Pagination.cursor(options)
@@ -359,7 +359,7 @@ defmodule WhoNeedHelp.Trust do
def moderate_report(%Scope{user: moderator}, report_id, attrs) do
with {:ok, report_id} <- cast_id(report_id),
- true <- Accounts.moderator_authorized?(moderator) do
+ true <- Accounts.authorized?(moderator, :moderation_manage) do
attrs =
attrs
|> stringify_keys()
@@ -413,7 +413,7 @@ defmodule WhoNeedHelp.Trust do
def report_evidence(%Scope{user: moderator}, report_id) do
with {:ok, report_id} <- cast_id(report_id),
- true <- Accounts.moderator_authorized?(moderator) do
+ true <- Accounts.authorized?(moderator, :moderation_evidence_view) do
report =
Report
|> Repo.get(report_id)
@@ -667,7 +667,7 @@ defmodule WhoNeedHelp.Trust do
end
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)
cursor = Pagination.cursor(options)
@@ -686,7 +686,7 @@ defmodule WhoNeedHelp.Trust do
def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do
with {:ok, signal_id} <- cast_id(signal_id),
- true <- Accounts.moderator_authorized?(moderator) do
+ true <- Accounts.authorized?(moderator, :moderation_manage) do
attrs =
attrs
|> stringify_keys()
@@ -723,7 +723,7 @@ defmodule WhoNeedHelp.Trust do
def hide_request(%Scope{user: moderator}, request_id, reason) do
result =
with {:ok, request_id} <- cast_id(request_id),
- true <- Accounts.moderator_authorized?(moderator) do
+ true <- Accounts.authorized?(moderator, :moderation_manage) do
Repo.transact(fn ->
request =
HelpRequest
@@ -763,7 +763,7 @@ defmodule WhoNeedHelp.Trust do
def restore_request(%Scope{user: moderator}, request_id) do
result =
with {:ok, request_id} <- cast_id(request_id),
- true <- Accounts.moderator_authorized?(moderator) do
+ true <- Accounts.authorized?(moderator, :moderation_manage) do
Repo.transact(fn ->
request =
HelpRequest
@@ -798,7 +798,7 @@ defmodule WhoNeedHelp.Trust do
def hide_activity(%Scope{user: moderator}, activity_id, reason) do
result =
with {:ok, activity_id} <- cast_id(activity_id),
- true <- Accounts.moderator_authorized?(moderator) do
+ true <- Accounts.authorized?(moderator, :moderation_manage) do
Repo.transact(fn ->
activity =
Activity
@@ -838,7 +838,7 @@ defmodule WhoNeedHelp.Trust do
def restore_activity(%Scope{user: moderator}, activity_id) do
result =
with {:ok, activity_id} <- cast_id(activity_id),
- true <- Accounts.moderator_authorized?(moderator) do
+ true <- Accounts.authorized?(moderator, :moderation_manage) do
Repo.transact(fn ->
activity =
Activity
@@ -910,16 +910,42 @@ defmodule WhoNeedHelp.Trust do
end
end
- def moderate_role(%Scope{user: admin}, user_id, attrs) do
- Repo.transact(fn ->
- with {:ok, user} <- Accounts.change_user_role(admin, user_id, attrs),
- {:ok, _audit} <-
- audit(admin.id, "user.role_changed", "user", user.id, %{
- "role" => to_string(user.role)
- }) do
- {:ok, user}
- end
- end)
+ def set_staff_roles(%Scope{user: admin}, user_id, roles) do
+ result =
+ Repo.transact(fn ->
+ with {:ok, %{user: user} = change} <- Accounts.set_staff_roles(admin, user_id, roles),
+ {:ok, _audit} <-
+ audit(admin.id, "user.staff_roles_changed", "user", user.id, %{
+ "roles" => Enum.map(Accounts.loaded_staff_roles(user), &to_string/1)
+ }) do
+ {:ok, change}
+ 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
def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do
@@ -934,6 +960,43 @@ defmodule WhoNeedHelp.Trust do
|> Repo.insert()
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(ids) do
diff --git a/lib/who_need_help_web/components/admin_components.ex b/lib/who_need_help_web/components/admin_components.ex
new file mode 100644
index 0000000..49add7e
--- /dev/null
+++ b/lib/who_need_help_web/components/admin_components.ex
@@ -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"""
+
+
+
+
+
+
+
+
+ <.icon name="hero-lock-closed" class="size-5" />
+
+
+
+ {gettext("Restricted workspace")}
+
+
{@title}
+
+ {@description}
+
+
+
+
+
+
{render_slot(@inner_block)}
+
+
+
+ """
+ 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"""
+
+ <.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" />
+ {render_slot(@inner_block)}
+
+
+ """
+ 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
diff --git a/lib/who_need_help_web/components/layouts.ex b/lib/who_need_help_web/components/layouts.ex
index 2f142eb..77950e4 100644
--- a/lib/who_need_help_web/components/layouts.ex
+++ b/lib/who_need_help_web/components/layouts.ex
@@ -142,16 +142,13 @@ defmodule WhoNeedHelpWeb.Layouts do
<.link href={~p"/users/settings"}>{gettext("Account settings")}
-
- <.link navigate={~p"/moderation"}>{gettext("Moderation")}
-
-
- <.link navigate={~p"/support/operations"}>{gettext("Support queue")}
+
+ <.link navigate={~p"/admin"}>{gettext("Staff workspace")}
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}
@@ -387,16 +384,13 @@ defmodule WhoNeedHelpWeb.Layouts do
<.link href={~p"/users/settings"}>{gettext("Account settings")}
-
- <.link navigate={~p"/moderation"}>{gettext("Moderation")}
-
-
- <.link navigate={~p"/support/operations"}>{gettext("Support queue")}
+
+ <.link navigate={~p"/admin"}>{gettext("Staff workspace")}
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}
diff --git a/lib/who_need_help_web/live/admin_audit_live.ex b/lib/who_need_help_web/live/admin_audit_live.ex
new file mode 100644
index 0000000..e19e645
--- /dev/null
+++ b/lib/who_need_help_web/live/admin_audit_live.ex
@@ -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"""
+
+ <.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" />
+
+
+
+
+ {gettext("No audit events match these filters.")}
+
+
+
+ {event.action}
+ {event.inserted_at}
+
+
+
+
+ {gettext("Actor")}
+
+
+ {(event.actor && (event.actor.display_name || event.actor.email)) || gettext("System")}
+
+
+
+
+ {gettext("Target")}
+
+
+ {event.target_type}: {event.target_id}
+
+
+
+
+ {gettext("Metadata")}
+
+
+ {Jason.encode!(event.metadata, pretty: true)}
+
+
+
+
+
+
+
+
+
+
+ {gettext("Time")} {gettext("Actor")} {gettext("Action")}
+ {gettext("Target")}
+ {gettext("Metadata")}
+
+
+
+
+
+ {gettext("No audit events match these filters.")}
+
+
+
+ {event.inserted_at}
+
+ {(event.actor &&
+ (event.actor.display_name || event.actor.email)) ||
+ gettext("System")}
+ {event.actor_id}
+
+
+ {event.action}
+
+ {event.target_type}
+ {event.target_id}
+
+
+
+ {Jason.encode!(event.metadata, pretty: true)}
+
+
+
+
+
+ {gettext(
+ "Load more events"
+ )}
+
+ """
+ end
+end
diff --git a/lib/who_need_help_web/live/admin_dashboard_live.ex b/lib/who_need_help_web/live/admin_dashboard_live.ex
new file mode 100644
index 0000000..0f416c8
--- /dev/null
+++ b/lib/who_need_help_web/live/admin_dashboard_live.ex
@@ -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"""
+
+
+ <.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"
+ />
+
+
+
+
+
+
{gettext("Your staff access")}
+
+ {gettext(
+ "An account may hold several roles. Permissions are the union of those roles; administrator includes every permission."
+ )}
+
+
+
+
+ {role_label(role)}
+
+
+
+
+
+ """
+ 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"
+ >
+
+
+ <.icon name={@icon} class="size-5" />
+
+ <.icon
+ name="hero-arrow-up-right"
+ class="size-5 opacity-40 transition group-hover:opacity-100"
+ />
+
+ {@value}
+ {@label}
+
+ """
+ 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
diff --git a/lib/who_need_help_web/live/admin_users_live.ex b/lib/who_need_help_web/live/admin_users_live.ex
new file mode 100644
index 0000000..1efe468
--- /dev/null
+++ b/lib/who_need_help_web/live/admin_users_live.ex
@@ -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"""
+
+ <.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)})
+ }
+ />
+
+
+
+
+ <% status_form =
+ to_form(
+ %{
+ "moderation_status" => to_string(user.moderation_status),
+ "moderation_note" => user.moderation_note || ""
+ },
+ as: :moderation,
+ id: "status-#{user.id}"
+ ) %>
+
+
+
+
{user.display_name}
+ {user.moderation_status}
+
+
{user.email}
+
+ {gettext(
+ "Regular user"
+ )}
+ {role_label(role)}
+
+
+
+ <.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"
+ />
+
{gettext(
+ "Save"
+ )}
+
+
+
+ <.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"
+ >
+
+
+ {gettext("Staff roles")}
+
+
+
+
+ {role_label(role)}
+ {role_description(
+ role
+ )}
+
+
+
+ {gettext(
+ "Update roles"
+ )}
+
+
+
+
+
+ {gettext("No users match these filters.")}
+
+
+
+ {gettext(
+ "Load more users"
+ )}
+
+ """
+ end
+end
diff --git a/lib/who_need_help_web/live/moderation_live.ex b/lib/who_need_help_web/live/moderation_live.ex
index 53dfd16..6a0417e 100644
--- a/lib/who_need_help_web/live/moderation_live.ex
+++ b/lib/who_need_help_web/live/moderation_live.ex
@@ -1,7 +1,8 @@
defmodule WhoNeedHelpWeb.ModerationLive do
use WhoNeedHelpWeb, :live_view
- alias WhoNeedHelp.{Accounts, Catalog, Trust}
+ alias WhoNeedHelp.{Catalog, Trust}
+ alias WhoNeedHelpWeb.AdminComponents
@impl true
def mount(_params, _session, socket) do
@@ -35,22 +36,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
)
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
respond(
socket,
@@ -173,15 +158,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
{:noreply, append_page(socket, :proposals, page)}
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
{:noreply, socket |> put_flash(:info, message) |> load()}
end
@@ -198,7 +174,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
reports = Trust.paginate_reports(socket.assigns.current_scope)
signals = Trust.paginate_abuse_signals(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
|> assign(:reports, reports.entries)
@@ -207,8 +182,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|> assign(:signals_cursor, signals.next_cursor)
|> assign(:proposals, proposals.entries)
|> assign(:proposals_cursor, proposals.next_cursor)
- |> assign(:users, users.entries)
- |> assign(:users_cursor, users.next_cursor)
|> assign(:categories, Catalog.list_all_categories())
end
@@ -225,7 +198,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp cursor_assign(:reports), do: :reports_cursor
defp cursor_assign(:signals), do: :signals_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(: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(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(:repeated_pair), do: gettext("Repeated participant pair")
@@ -316,30 +283,20 @@ defmodule WhoNeedHelpWeb.ModerationLive do
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
def render(assigns) do
~H"""
-
- {gettext("RESTRICTED WORKSPACE")}
-
-
{gettext("Moderation")}
-
- <.link navigate={~p"/analytics"} class="btn btn-outline btn-sm">
- {gettext("Product analytics")}
-
- <.link navigate={~p"/support/operations"} class="btn btn-outline btn-sm">
- {gettext("Support operations")}
-
-
-
-
- {gettext("Decisions and access to reported chat evidence are written to the audit log.")}
-
-
+
{gettext("Reports")}
@@ -651,106 +608,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
{gettext("Load more")}
-
-
- {gettext("Accounts")}
-
-
-
-
- {gettext("User")}
- {gettext("Role")}
- {gettext("Status")}
- {gettext("Decision")}
-
-
-
-
- <% 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}"
- ) %>
-
- {user.display_name}
- {user.email}
-
- {role_label(user.role)}
- {status_label(user.moderation_status)}
-
- <.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")}
-
-
- {gettext("Administrator access is required to moderate staff accounts.")}
-
- <.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")}
-
-
-
-
-
-
-
-
- {gettext("Load more")}
-
-
-
+
"""
end
end
diff --git a/lib/who_need_help_web/live/product_analytics_live.ex b/lib/who_need_help_web/live/product_analytics_live.ex
index 647ce0c..b6786dc 100644
--- a/lib/who_need_help_web/live/product_analytics_live.ex
+++ b/lib/who_need_help_web/live/product_analytics_live.ex
@@ -2,6 +2,7 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.ProductAnalytics
+ alias WhoNeedHelpWeb.AdminComponents
@impl true
def mount(_params, _session, socket) do
@@ -35,23 +36,18 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
@impl true
def render(assigns) do
~H"""
-
- {gettext("RESTRICTED WORKSPACE")}
-
-
-
{gettext("Product analytics")}
-
- {gettext(
- "Daily aggregate counters only. No user ID, email, coordinate, request text, chat text, medicine name, or device credential is stored here."
- )}
-
-
- <.link navigate={~p"/moderation"} class="btn btn-outline btn-sm">
- {gettext("Back to moderation")}
-
-
-
-
+
+
@@ -81,11 +77,11 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
:if={@metrics_cursor}
type="button"
phx-click="load-more"
- class="btn btn-outline btn-sm mt-4"
+ class="btn btn-outline btn-sm"
>
{gettext("Load more")}
-
+
"""
end
end
diff --git a/lib/who_need_help_web/live/support_operations_live.ex b/lib/who_need_help_web/live/support_operations_live.ex
index 2ad3e6f..a25c767 100644
--- a/lib/who_need_help_web/live/support_operations_live.ex
+++ b/lib/who_need_help_web/live/support_operations_live.ex
@@ -1,16 +1,33 @@
defmodule WhoNeedHelpWeb.SupportOperationsLive do
use WhoNeedHelpWeb, :live_view
- alias WhoNeedHelp.{ContentRemoval, Support}
+ alias WhoNeedHelp.{Accounts, ContentRemoval, Support}
+ alias WhoNeedHelpWeb.AdminComponents
@impl true
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,
socket
|> 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(: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()}
end
@@ -36,6 +53,26 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
)
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
case Support.deletion_assessment(socket.assigns.current_scope, id) do
{:ok, assessment} ->
@@ -48,8 +85,9 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
def handle_event("load-more-support", _params, socket) do
page =
- Support.paginate_for_staff(socket.assigns.current_scope,
- after: socket.assigns.support_cursor
+ Support.paginate_for_staff(
+ socket.assigns.current_scope,
+ support_options(socket, socket.assigns.support_cursor)
)
{: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
page =
- ContentRemoval.paginate_for_staff(socket.assigns.current_scope,
- after: socket.assigns.removal_cursor
+ ContentRemoval.paginate_for_staff(
+ socket.assigns.current_scope,
+ legal_options(socket, socket.assigns.removal_cursor)
)
{:noreply, append_page(socket, :removal_notices, :removal_cursor, page)}
@@ -73,14 +112,69 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
end
defp load(socket) do
- support = Support.paginate_for_staff(socket.assigns.current_scope)
- removals = ContentRemoval.paginate_for_staff(socket.assigns.current_scope)
-
socket
- |> assign(:support_requests, support.entries)
- |> assign(:support_cursor, support.next_cursor)
- |> assign(:removal_notices, removals.entries)
- |> assign(:removal_cursor, removals.next_cursor)
+ |> load_support()
+ |> load_removals()
+ end
+
+ 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
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(:contact_not_verified), do: gettext("Verify the requester contact first.")
defp error_message(:account_not_found), do: gettext("The linked account no longer exists.")
+
+ defp error_message(:invalid_assignee),
+ do: gettext("The selected assignee cannot manage this queue.")
+
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
defp error_message(_reason), do: gettext("Could not update the request. Please try again.")
@@ -129,27 +227,86 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
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
def render(assigns) do
~H"""
-
- {gettext("RESTRICTED WORKSPACE")}
-
-
-
{gettext("Support operations")}
-
- {gettext(
- "Support requests and legal removal notices are separate queues. Decisions and operator identity are audited."
- )}
-
-
- <.link navigate={~p"/moderation"} class="btn btn-outline btn-sm">
- {gettext("Trust and safety moderation")}
-
-
-
-
+
+
{gettext("Content removal and TAKE IT DOWN")}
+ <.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)}
+ />
+
to_string(notice.status),
+ "assigned_to_id" => notice.assigned_to_id || "",
"resolution_note" => notice.resolution_note || ""
},
"removal-#{notice.id}"
@@ -178,6 +336,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("review by %{time}", time: notice.response_due_at)}
+
+ {if notice.assigned_to,
+ do:
+ gettext("Assigned: %{name}",
+ name: notice.assigned_to.display_name || notice.assigned_to.email
+ ),
+ else: gettext("Unassigned")}
+
{gettext("Submitter")}
@@ -226,7 +392,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Audited update")}
-
+
<.input
field={form[:status]}
@@ -247,6 +413,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Choose the state that will appear in the case history.")}
+
+ <.input
+ field={form[:assigned_to_id]}
+ type="select"
+ label={gettext("Assigned operator")}
+ options={assignment_options(@legal_assignees)}
+ />
+
<.input
field={form[:resolution_note]}
@@ -307,14 +481,64 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
-
+
{gettext("General support and data requests")}
+ <.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 =
scoped_form(
%{
"status" => to_string(request.status),
+ "assigned_to_id" => request.assigned_to_id || "",
"response" => ""
},
"support-#{request.id}"
@@ -326,6 +550,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("contact unverified")}
{support_kind(request.kind)}
+
+ {if request.assigned_to,
+ do:
+ gettext("Assigned: %{name}",
+ name: request.assigned_to.display_name || request.assigned_to.email
+ ),
+ else: gettext("Unassigned")}
+
{request.subject}
{request.contact_email}
@@ -427,7 +659,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Audited update")}
-
+
<.input
field={form[:status]}
@@ -446,6 +678,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Choose the state that will appear in the case history.")}
+
+ <.input
+ field={form[:assigned_to_id]}
+ type="select"
+ label={gettext("Assigned operator")}
+ options={assignment_options(@support_assignees)}
+ />
+
<.input
field={form[:response]}
@@ -505,7 +745,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Load more")}
-
+
"""
end
end
diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex
index 3301a53..4d4c2ff 100644
--- a/lib/who_need_help_web/router.ex
+++ b/lib/who_need_help_web/router.ex
@@ -178,10 +178,35 @@ defmodule WhoNeedHelpWeb.Router do
live "/reports", ReportLive, :index
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,
- on_mount: [{WhoNeedHelpWeb.UserAuth, :ensure_moderator}] do
+ on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :moderation_view}}] do
live "/moderation", ModerationLive, :index
+ end
+
+ live_session :analytics,
+ on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :analytics_view}}] do
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
end
end
diff --git a/lib/who_need_help_web/user_auth.ex b/lib/who_need_help_web/user_auth.ex
index 76ca014..93f007d 100644
--- a/lib/who_need_help_web/user_auth.ex
+++ b/lib/who_need_help_web/user_auth.ex
@@ -294,16 +294,57 @@ defmodule WhoNeedHelpWeb.UserAuth do
end
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
%Scope{user: %Accounts.User{} = user} = scope ->
- if Accounts.moderator_authorized?(user) do
+ if Accounts.authorized?(user, permission) 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("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")
{:halt, socket}
diff --git a/priv/repo/migrations/20260801002933_add_staff_rbac_and_case_assignments.exs b/priv/repo/migrations/20260801002933_add_staff_rbac_and_case_assignments.exs
new file mode 100644
index 0000000..c245044
--- /dev/null
+++ b/priv/repo/migrations/20260801002933_add_staff_rbac_and_case_assignments.exs
@@ -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
diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex
index f87a965..2000d02 100644
--- a/test/support/fixtures/accounts_fixtures.ex
+++ b/test/support/fixtures/accounts_fixtures.ex
@@ -7,7 +7,9 @@ defmodule WhoNeedHelp.AccountsFixtures do
import Ecto.Query
alias WhoNeedHelp.Accounts
+ alias WhoNeedHelp.Accounts.StaffRoleAssignment
alias WhoNeedHelp.Accounts.Scope
+ alias WhoNeedHelp.Repo
def unique_user_email, do: "user#{System.unique_integer()}@example.com"
def valid_user_password, do: "hello world!"
@@ -43,6 +45,28 @@ defmodule WhoNeedHelp.AccountsFixtures do
user
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
user = user_fixture()
user_scope_fixture(user)
diff --git a/test/who_need_help/accounts_test.exs b/test/who_need_help/accounts_test.exs
index a63b3c0..7d0c41b 100644
--- a/test/who_need_help/accounts_test.exs
+++ b/test/who_need_help/accounts_test.exs
@@ -11,7 +11,7 @@ defmodule WhoNeedHelp.AccountsTest do
alias WhoNeedHelp.Accounts
import WhoNeedHelp.AccountsFixtures
- alias WhoNeedHelp.Accounts.{SocialIdentity, User, UserToken}
+ alias WhoNeedHelp.Accounts.{SocialIdentity, StaffPermissions, User, UserToken}
describe "get_user_by_email/1" do
test "does not return the user if the email does not exist" do
@@ -103,11 +103,12 @@ defmodule WhoNeedHelp.AccountsTest do
|> set_password()
|> Ecto.Changeset.change(
bio: "A short public bio",
- role: :admin,
moderation_note: "private moderator note"
)
|> Repo.update!()
+ _user = grant_staff_roles(user, [:admin])
+
{:ok, identity} =
Accounts.add_social_identity(user, %{
"provider" => "telegram",
@@ -125,7 +126,7 @@ defmodule WhoNeedHelp.AccountsTest do
assert identity_id == identity.id
assert is_nil(public_user.email)
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)
end
@@ -204,6 +205,40 @@ defmodule WhoNeedHelp.AccountsTest do
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
test "accepts only encrypted thank-you and social profile URLs" do
user = user_fixture()
diff --git a/test/who_need_help/activities_test.exs b/test/who_need_help/activities_test.exs
index 09cf8fc..6fd4b4e 100644
--- a/test/who_need_help/activities_test.exs
+++ b/test/who_need_help/activities_test.exs
@@ -668,10 +668,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
end
test "activity message reports expose only the linked group to audited moderators", context do
- moderator =
- user_fixture(display_name: "Activity moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:moderator], display_name: "Activity moderator")
attrs = Map.put(context.attrs, "capacity", 3)
{:ok, activity} = Activities.create_activity(context.organizer_scope, attrs)
@@ -738,10 +735,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
end
test "moderators can hide and restore a reported activity", context do
- moderator =
- user_fixture(display_name: "Activity moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:moderator], display_name: "Activity moderator")
moderator_scope = user_scope_fixture(moderator)
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
diff --git a/test/who_need_help/product_analytics_test.exs b/test/who_need_help/product_analytics_test.exs
index d3d2246..d44820c 100644
--- a/test/who_need_help/product_analytics_test.exs
+++ b/test/who_need_help/product_analytics_test.exs
@@ -23,9 +23,8 @@ defmodule WhoNeedHelp.ProductAnalyticsTest do
assert ProductAnalytics.paginate(ordinary).entries == []
moderator =
- user_fixture()
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ [:analyst]
+ |> staff_user_fixture()
|> user_scope_fixture()
assert [%DailyMetric{metric: "request.created", count: 2}] =
diff --git a/test/who_need_help/support_and_content_removal_test.exs b/test/who_need_help/support_and_content_removal_test.exs
index 9fb9827..ca49d99 100644
--- a/test/who_need_help/support_and_content_removal_test.exs
+++ b/test/who_need_help/support_and_content_removal_test.exs
@@ -173,10 +173,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert request.contact_verified_at
assert_email_sent()
- moderator =
- user_fixture()
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:support, :legal])
assert_email_sent()
@@ -206,6 +203,64 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
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
previous = Application.get_env(:who_need_help, :support_inbox_address)
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
@@ -440,9 +495,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
end
defp moderator_scope do
- user_fixture()
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ staff_user_fixture([:support, :legal])
|> user_scope_fixture()
end
end
diff --git a/test/who_need_help/trust_safety_test.exs b/test/who_need_help/trust_safety_test.exs
index fef4541..6ad9ad4 100644
--- a/test/who_need_help/trust_safety_test.exs
+++ b/test/who_need_help/trust_safety_test.exs
@@ -3,7 +3,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
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.Repo
alias WhoNeedHelp.Tracking.Position
@@ -86,10 +86,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
outsider = user_fixture(display_name: "Report history outsider")
assert [] = Trust.list_reports_for_user(user_scope_fixture(outsider))
- moderator =
- user_fixture(display_name: "Report history moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:moderator], display_name: "Report history moderator")
assert {:ok, resolved} =
Trust.moderate_report(user_scope_fixture(moderator), report.id, %{
@@ -113,15 +110,9 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end
test "forged moderation identifiers return not found without crashing", context do
- moderator =
- user_fixture(display_name: "Moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:moderator], display_name: "Moderator")
- admin =
- user_fixture(display_name: "Administrator")
- |> Ecto.Changeset.change(role: :admin)
- |> Repo.update!()
+ admin = staff_user_fixture([:admin], display_name: "Administrator")
moderator_scope = user_scope_fixture(moderator)
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.restore_activity(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.reject_proposal(moderator_scope, id, "review")
@@ -154,10 +145,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end
test "hiding a request requires a moderation reason", context do
- moderator =
- user_fixture(display_name: "Reason moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:moderator], display_name: "Reason moderator")
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
@@ -169,10 +157,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end
test "category moderation cannot cross help and activity taxonomies", context do
- moderator =
- user_fixture(display_name: "Category moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:moderator], display_name: "Category moderator")
moderator_scope = user_scope_fixture(moderator)
activity_category = Catalog.list_categories(:activity) |> List.first()
@@ -334,10 +319,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end
test "reports expose only linked evidence to moderators and audit that access", context do
- moderator =
- user_fixture(display_name: "Moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:moderator], display_name: "Moderator")
moderator_scope = user_scope_fixture(moderator)
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
@@ -669,55 +651,39 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end
test "only an administrator can change roles and the last admin is protected", context do
- moderator =
- user_fixture(display_name: "Moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:moderator], display_name: "Moderator")
- admin =
- user_fixture(display_name: "Administrator")
- |> Ecto.Changeset.change(role: :admin)
- |> Repo.update!()
+ admin = staff_user_fixture([:admin], display_name: "Administrator")
assert {:error, :forbidden} =
- Trust.moderate_role(user_scope_fixture(moderator), context.helper.id, %{
- "role" => "moderator"
- })
+ Trust.set_staff_roles(user_scope_fixture(moderator), context.helper.id, ["moderator"])
assert {:ok, promoted} =
- Trust.moderate_role(user_scope_fixture(admin), context.helper.id, %{
- "role" => "moderator"
- })
+ Trust.set_staff_roles(user_scope_fixture(admin), context.helper.id, [
+ "support",
+ "moderator"
+ ])
- assert promoted.role == :moderator
+ assert Accounts.loaded_staff_roles(promoted) == [:moderator, :support]
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?(
from event in AuditEvent,
where:
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
test "restricted moderators lose authorization and moderators cannot suspend administrators",
context do
- admin =
- user_fixture(display_name: "Administrator")
- |> Ecto.Changeset.change(role: :admin)
- |> Repo.update!()
+ admin = staff_user_fixture([:admin], display_name: "Administrator")
- moderator =
- user_fixture(display_name: "Moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:moderator], display_name: "Moderator")
- active_moderator =
- user_fixture(display_name: "Active moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ active_moderator = staff_user_fixture([:moderator], display_name: "Active moderator")
assert WhoNeedHelp.Accounts.moderator_authorized?(moderator)
@@ -776,15 +742,9 @@ defmodule WhoNeedHelp.TrustSafetyTest do
end
test "moderators cannot restrict staff and restrictions require an internal note", context do
- admin =
- user_fixture(display_name: "Staff administrator")
- |> Ecto.Changeset.change(role: :admin)
- |> Repo.update!()
+ admin = staff_user_fixture([:admin], display_name: "Staff administrator")
- moderator =
- user_fixture(display_name: "Staff moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:moderator], display_name: "Staff moderator")
assert {:error, :forbidden} =
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
assert {:ok, admin} = Release.bootstrap_admin(context.helper.email)
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} =
Release.bootstrap_admin(context.requester.email)
diff --git a/test/who_need_help_web/controllers/support_controller_test.exs b/test/who_need_help_web/controllers/support_controller_test.exs
index 8b28c56..070ba11 100644
--- a/test/who_need_help_web/controllers/support_controller_test.exs
+++ b/test/who_need_help_web/controllers/support_controller_test.exs
@@ -216,10 +216,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
"details" => "This request verifies the private support conversation flow."
})
- moderator =
- user_fixture()
- |> Ecto.Changeset.change(role: :moderator)
- |> WhoNeedHelp.Repo.update!()
+ moderator = staff_user_fixture([:support])
{:ok, _resolved} =
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."
})
- moderator =
- user_fixture()
- |> Ecto.Changeset.change(role: :moderator)
- |> WhoNeedHelp.Repo.update!()
+ moderator = staff_user_fixture([:support])
{:ok, requester_view, _html} = live(conn, ~p"/support/cases/#{request.id}")
@@ -313,10 +307,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
end
test "renders separate support and removal queues for a moderator", %{conn: conn} do
- moderator =
- user_fixture()
- |> Ecto.Changeset.change(role: :moderator)
- |> WhoNeedHelp.Repo.update!()
+ moderator = staff_user_fixture([:support, :legal])
{:ok, support_request} =
WhoNeedHelp.Support.create_request(nil, %{
diff --git a/test/who_need_help_web/live/admin_live_test.exs b/test/who_need_help_web/live/admin_live_test.exs
new file mode 100644
index 0000000..24eb89e
--- /dev/null
+++ b/test/who_need_help_web/live/admin_live_test.exs
@@ -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
diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs
index a977eff..bad311c 100644
--- a/test/who_need_help_web/live/mutual_aid_live_test.exs
+++ b/test/who_need_help_web/live/mutual_aid_live_test.exs
@@ -33,10 +33,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"details" => "Review this request and keep the report private."
})
- moderator =
- user_fixture(display_name: "Report moderator")
- |> Ecto.Changeset.change(role: :moderator)
- |> Repo.update!()
+ moderator = staff_user_fixture([:moderator], display_name: "Report moderator")
{:ok, _report} =
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")
end
- test "moderator dashboard renders reports, signals, proposals, and accounts", %{
- conn: conn,
- user: user
- } do
- user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!()
+ test "moderator workspace renders reports, signals, and proposals without account management",
+ %{
+ conn: conn,
+ user: user
+ } do
+ grant_staff_roles(user, [:moderator])
category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Requester")
helper = user_fixture(display_name: "Helper")
@@ -1538,11 +1536,10 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Reports"
assert html =~ "Open abuse signals"
assert html =~ "Category proposals"
- assert html =~ "Accounts"
+ refute html =~ "Users & roles"
assert html =~ "Please review the matched conversation."
assert html =~ "Bicycle repair"
refute has_element?(view, "#moderation-user-status-#{user.id}")
- assert html =~ "Administrator access is required to moderate staff accounts."
assert has_element?(
view,
@@ -1562,7 +1559,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
user: user,
scope: participant_scope
} do
- user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!()
+ grant_staff_roles(user, [:moderator])
organizer = user_fixture(display_name: "Activity organizer")
Catalog.seed_defaults()
activity_category = Catalog.list_categories(:activity) |> List.first()
@@ -1615,7 +1612,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
conn: conn,
user: user
} do
- user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!()
+ grant_staff_roles(user, [:moderator])
subject = user_fixture(display_name: "Signal subject")
for sequence <- 1..25 do