Add permission-scoped staff administration workspace

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

View File

@ -66,7 +66,10 @@ local Codex CLI authenticated with their ChatGPT subscription.
disables rejected device registrations.
- 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

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

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

View File

@ -17,9 +17,13 @@ Who Need Help deliberately separates three mechanisms:
infringe a right. TAKE IT DOWN notices have a dedicated public form and a
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

View File

@ -122,8 +122,10 @@ does not reveal its private chat. Only a report targeting a specific Activity
message permits the moderator to load that Activity's group conversation, and
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.

View File

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

View File

@ -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 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 == ^id and assignment.role in ^allowed_roles and
user.moderation_status == :active
)
end
def moderator_authorized?(_user), do: false
def authorized?(_user, _permission), do: false
def admin?(%User{role: :admin}), do: true
def admin?(_user), 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)
def admin_authorized?(%User{id: id, role: :admin}),
do:
allowed_roles != [] and
Repo.exists?(
from user in User,
where: user.id == ^id and user.role == :admin and user.moderation_status == :active
from assignment in StaffRoleAssignment,
join: user in User,
on: user.id == assignment.user_id,
where:
assignment.user_id == ^user_id and assignment.role in ^allowed_roles and
user.moderation_status == :active
)
else
_ -> false
end
end
def admin_authorized?(_user), do: false
def staff?(user), do: permission?(user, :staff_access)
def staff_authorized?(user), do: authorized?(user, :staff_access)
def moderator?(user), do: permission?(user, :moderation_view)
def moderator_authorized?(user), do: authorized?(user, :moderation_view)
def admin?(user), do: :admin in loaded_staff_roles(user)
def admin_authorized?(user), do: authorized?(user, :staff_manage)
def list_users_for_moderation(%Scope{user: user}) do
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}

View File

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

View File

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

View File

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

View File

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

View File

@ -24,8 +24,6 @@ defmodule WhoNeedHelp.Accounts.User do
values: [:everyone, :verified_accounts, :completed_help_users, :nobody],
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

View File

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

View File

@ -185,7 +185,7 @@ defmodule WhoNeedHelp.Catalog do
end
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)

View File

@ -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,11 +128,10 @@ 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
true <- Accounts.authorized?(moderator, :legal_manage) do
with {:ok, attrs} <- normalize_assignment(normalize_keys(attrs), :legal_manage) do
attrs =
attrs
|> normalize_keys()
|> Map.merge(%{
Map.merge(attrs, %{
"reviewed_at" => DateTime.utc_now(:second),
"reviewed_by_id" => moderator.id
})
@ -150,16 +152,18 @@ defmodule WhoNeedHelp.ContentRemoval do
"content_removal_notice",
notice.id,
%{
"status" => to_string(notice.status)
"status" => to_string(notice.status),
"assigned_to_id" => notice.assigned_to_id
}
) do
{:ok, notice}
{:ok, Repo.preload(notice, :assigned_to, force: true)}
end
else
{:error, :not_found}
end
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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,10 +170,11 @@ 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"])
with {:ok, attrs} <- normalize_assignment(attrs, :support_manage) do
attrs =
attrs
|> Map.delete("response")
@ -211,7 +216,8 @@ defmodule WhoNeedHelp.Support do
"support_request",
request.id,
%{
"status" => to_string(request.status)
"status" => to_string(request.status),
"assigned_to_id" => request.assigned_to_id
}
) do
{:ok, preload_conversation(request)}
@ -222,6 +228,7 @@ defmodule WhoNeedHelp.Support do
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)

View File

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

View File

@ -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
def set_staff_roles(%Scope{user: admin}, user_id, roles) do
result =
Repo.transact(fn ->
with {:ok, user} <- Accounts.change_user_role(admin, user_id, attrs),
with {:ok, %{user: user} = change} <- Accounts.set_staff_roles(admin, user_id, roles),
{:ok, _audit} <-
audit(admin.id, "user.role_changed", "user", user.id, %{
"role" => to_string(user.role)
audit(admin.id, "user.staff_roles_changed", "user", user.id, %{
"roles" => Enum.map(Accounts.loaded_staff_roles(user), &to_string/1)
}) do
{:ok, user}
{: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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,293 @@
defmodule WhoNeedHelpWeb.AdminUsersLive do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Accounts, Trust}
alias WhoNeedHelp.Accounts.StaffPermissions
alias WhoNeedHelpWeb.AdminComponents
@impl true
def mount(_params, _session, socket) do
filters = %{"search" => "", "status" => "", "role" => ""}
{:ok,
socket
|> assign(:page_title, gettext("Users and roles"))
|> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filters))
|> assign(:roles, StaffPermissions.roles())
|> load_users()}
end
@impl true
def handle_event("filter", %{"filters" => filters}, socket) do
filters = Map.merge(socket.assigns.filters, filters)
{:noreply,
socket
|> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filters))
|> load_users()}
end
def handle_event("load-more", _params, socket) do
page =
Accounts.paginate_users_for_moderation(
socket.assigns.current_scope,
user_options(socket, socket.assigns.users_cursor)
)
existing_ids = MapSet.new(socket.assigns.users, & &1.id)
{:noreply,
socket
|> assign(
:users,
socket.assigns.users ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
)
|> assign(:users_cursor, page.next_cursor)}
end
def handle_event("moderate-user", %{"id" => id, "moderation" => params}, socket) do
respond(
socket,
Trust.moderate_user(socket.assigns.current_scope, id, params),
gettext("Account status updated.")
)
end
def handle_event("set-staff-roles", %{"id" => id, "staff" => params}, socket) do
roles = params |> Map.get("roles", []) |> List.wrap() |> Enum.reject(&(&1 == ""))
respond(
socket,
Trust.set_staff_roles(socket.assigns.current_scope, id, roles),
gettext("Staff roles updated.")
)
end
defp respond(socket, {:ok, _record}, message),
do: {:noreply, socket |> put_flash(:info, message) |> load_users()}
defp respond(socket, {:error, reason}, _message),
do: {:noreply, put_flash(socket, :error, error_message(reason))}
defp load_users(socket) do
page =
Accounts.paginate_users_for_moderation(socket.assigns.current_scope, user_options(socket))
socket |> assign(:users, page.entries) |> assign(:users_cursor, page.next_cursor)
end
defp user_options(socket, cursor \\ nil) do
filters = socket.assigns.filters
[search: filters["search"], status: filters["status"], role: filters["role"], after: cursor]
end
defp error_message(:forbidden),
do:
gettext(
"You do not have permission for this action. Role changes also require a recent sign-in."
)
defp error_message(:cannot_restrict_self),
do: gettext("You cannot restrict or suspend your own account.")
defp error_message(:last_admin),
do:
gettext("The last active administrator cannot lose administrator access or be restricted.")
defp error_message(:invalid_roles), do: gettext("One or more staff roles are invalid.")
defp error_message(:not_found), do: gettext("The selected account is no longer available.")
defp error_message(%Ecto.Changeset{}), do: gettext("Check the status and internal note.")
defp error_message(_reason), do: gettext("The account could not be updated.")
defp role_label(:support), do: gettext("Support")
defp role_label(:moderator), do: gettext("Moderator")
defp role_label(:legal), do: gettext("Legal")
defp role_label(:analyst), do: gettext("Analyst")
defp role_label(:admin), do: gettext("Administrator")
defp role_description(:support), do: gettext("Support conversations and verified support cases")
defp role_description(:moderator),
do: gettext("Reports, safety signals, user restrictions, and categories")
defp role_description(:legal), do: gettext("Content-removal and legal notice queue")
defp role_description(:analyst), do: gettext("Privacy-preserving aggregate product analytics")
defp role_description(:admin), do: gettext("All permissions, staff roles, and audit log")
defp target_has_staff_roles?(user), do: Accounts.loaded_staff_roles(user) != []
defp can_moderate?(actor, target) do
Accounts.permission?(actor, :users_moderate) and
(not target_has_staff_roles?(target) or Accounts.permission?(actor, :staff_manage))
end
@impl true
def render(assigns) do
~H"""
<AdminComponents.shell
flash={@flash}
current_scope={@current_scope}
active={:users}
title={gettext("Users and staff access")}
description={
gettext(
"Search accounts, restrict or suspend access, and combine fixed staff roles without creating ambiguous custom permissions."
)
}
>
<.form
for={@filter_form}
id="admin-user-filters"
phx-change="filter"
class="grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-[minmax(0,1fr)_12rem_12rem]"
>
<.input
field={@filter_form[:search]}
type="search"
label={gettext("Search users")}
placeholder={gettext("Email or display name")}
phx-debounce="300"
/>
<.input
field={@filter_form[:status]}
type="select"
label={gettext("Account status")}
options={[
{gettext("Any status"), ""},
{gettext("Active"), "active"},
{gettext("Restricted"), "restricted"},
{gettext("Suspended"), "suspended"}
]}
/>
<.input
field={@filter_form[:role]}
type="select"
label={gettext("Staff role")}
options={
[{gettext("Any role"), ""}, {gettext("No staff role"), "user"}] ++
Enum.map(@roles, &{role_label(&1), Atom.to_string(&1)})
}
/>
</.form>
<div class="space-y-4">
<article
:for={user <- @users}
class="rounded-3xl border border-base-300 bg-base-100 p-4 sm:p-6"
>
<% status_form =
to_form(
%{
"moderation_status" => to_string(user.moderation_status),
"moderation_note" => user.moderation_note || ""
},
as: :moderation,
id: "status-#{user.id}"
) %>
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="truncate text-lg font-black">{user.display_name}</h2>
<span class={[
"badge",
user.moderation_status == :active && "badge-success badge-outline",
user.moderation_status == :restricted && "badge-warning",
user.moderation_status == :suspended && "badge-error"
]}>{user.moderation_status}</span>
</div>
<p class="mt-1 break-all text-sm text-base-content/60">{user.email}</p>
<div class="mt-3 flex flex-wrap gap-2">
<span :if={Accounts.loaded_staff_roles(user) == []} class="badge badge-ghost">{gettext(
"Regular user"
)}</span>
<span
:for={role <- Accounts.loaded_staff_roles(user)}
class="badge badge-primary badge-outline"
>{role_label(role)}</span>
</div>
</div>
<.form
:if={can_moderate?(@current_scope.user, user)}
for={status_form}
id={"moderate-user-#{user.id}"}
phx-submit="moderate-user"
phx-value-id={user.id}
class="grid min-w-0 gap-3 sm:grid-cols-[11rem_minmax(14rem,1fr)_auto] xl:w-[42rem]"
>
<.input
field={status_form[:moderation_status]}
type="select"
label={gettext("Status")}
options={[
{gettext("Active"), "active"},
{gettext("Restricted"), "restricted"},
{gettext("Suspended"), "suspended"}
]}
/>
<.input
field={status_form[:moderation_note]}
label={gettext("Internal note")}
maxlength="1000"
/>
<button class="btn btn-primary self-end" phx-disable-with={gettext("Saving…")}>{gettext(
"Save"
)}</button>
</.form>
</div>
<.form
:if={Accounts.permission?(@current_scope.user, :staff_manage)}
for={to_form(%{}, as: :staff, id: "roles-#{user.id}")}
id={"staff-roles-#{user.id}"}
phx-submit="set-staff-roles"
phx-value-id={user.id}
class="mt-5 border-t border-base-300 pt-5"
>
<div class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<fieldset class="grid min-w-0 flex-1 gap-2 sm:grid-cols-2 xl:grid-cols-5">
<legend class="sr-only">{gettext("Staff roles")}</legend>
<input type="hidden" name="staff[roles][]" value="" />
<label
:for={role <- @roles}
class="flex cursor-pointer items-start gap-3 rounded-2xl border border-base-300 p-3 hover:border-primary/40"
>
<input
type="checkbox"
name="staff[roles][]"
value={Atom.to_string(role)}
checked={role in Accounts.loaded_staff_roles(user)}
class="checkbox checkbox-primary mt-0.5"
/>
<span class="min-w-0">
<span class="block font-bold">{role_label(role)}</span>
<span class="mt-0.5 block text-xs leading-5 text-base-content/55">{role_description(
role
)}</span>
</span>
</label>
</fieldset>
<button class="btn btn-warning shrink-0" phx-disable-with={gettext("Updating…")}>{gettext(
"Update roles"
)}</button>
</div>
</.form>
</article>
<p
:if={@users == []}
class="rounded-3xl border border-dashed border-base-300 p-10 text-center text-base-content/60"
>
{gettext("No users match these filters.")}
</p>
</div>
<button :if={@users_cursor} phx-click="load-more" class="btn btn-outline">{gettext(
"Load more users"
)}</button>
</AdminComponents.shell>
"""
end
end

View File

@ -1,7 +1,8 @@
defmodule WhoNeedHelpWeb.ModerationLive do
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"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div>
<div class="flex flex-wrap items-end justify-between gap-3">
<h1 class="mt-1 text-4xl font-black">{gettext("Moderation")}</h1>
<div class="flex flex-wrap gap-2">
<.link navigate={~p"/analytics"} class="btn btn-outline btn-sm">
{gettext("Product analytics")}
</.link>
<.link navigate={~p"/support/operations"} class="btn btn-outline btn-sm">
{gettext("Support operations")}
</.link>
</div>
</div>
<p class="mt-2 text-base-content/60">
{gettext("Decisions and access to reported chat evidence are written to the audit log.")}
</p>
<AdminComponents.shell
flash={@flash}
current_scope={@current_scope}
active={:moderation}
title={gettext("Trust and safety")}
description={
gettext(
"Review reports and automated abuse signals, inspect only scoped evidence, and moderate category proposals. Every sensitive action is audited."
)
}
>
<section class="mt-8">
<h2 class="text-2xl font-bold">{gettext("Reports")}</h2>
<div class="mt-4 space-y-4">
@ -651,106 +608,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
{gettext("Load more")}
</button>
</section>
<section class="mt-10">
<h2 class="text-2xl font-bold">{gettext("Accounts")}</h2>
<div class="mt-4 overflow-x-auto rounded-2xl border border-base-300">
<table class="table">
<thead>
<tr>
<th>{gettext("User")}</th>
<th>{gettext("Role")}</th>
<th>{gettext("Status")}</th>
<th>{gettext("Decision")}</th>
</tr>
</thead>
<tbody>
<tr :for={user <- @users}>
<% user_form =
scoped_form(
%{
"moderation_status" => to_string(user.moderation_status),
"moderation_note" => user.moderation_note || ""
},
:moderation,
"user-status-#{user.id}"
) %>
<% role_form =
scoped_form(
%{"role" => to_string(user.role)},
:moderation,
"user-role-#{user.id}"
) %>
<td>
{user.display_name}
<div class="text-xs opacity-50">{user.email}</div>
</td>
<td>{role_label(user.role)}</td>
<td>{status_label(user.moderation_status)}</td>
<td>
<.form
:if={can_moderate_account?(@current_scope.user, user)}
for={user_form}
phx-submit="moderate-user"
phx-value-id={user.id}
class="flex min-w-[28rem] gap-2"
>
<.input
field={user_form[:moderation_status]}
type="select"
options={[
{gettext("Active"), "active"},
{gettext("Restricted"), "restricted"},
{gettext("Suspended"), "suspended"}
]}
/>
<.input
field={user_form[:moderation_note]}
placeholder={gettext("Internal note")}
/>
<.button class="btn btn-sm btn-primary self-end">{gettext("Save")}</.button>
</.form>
<p
:if={not can_moderate_account?(@current_scope.user, user)}
class="text-xs text-base-content/55"
>
{gettext("Administrator access is required to moderate staff accounts.")}
</p>
<.form
:if={@current_scope.user.role == :admin}
for={role_form}
phx-submit="moderate-role"
phx-value-id={user.id}
class="mt-2 flex gap-2"
>
<.input
field={role_form[:role]}
type="select"
options={[
{gettext("User"), "user"},
{gettext("Moderator"), "moderator"},
{gettext("Administrator"), "admin"}
]}
/>
<.button class="btn btn-sm btn-warning self-end">
{gettext("Change role")}
</.button>
</.form>
</td>
</tr>
</tbody>
</table>
</div>
<button
:if={@users_cursor}
type="button"
phx-click="load-more-users"
class="btn btn-outline btn-sm mt-4"
>
{gettext("Load more")}
</button>
</section>
</Layouts.app>
</AdminComponents.shell>
"""
end
end

View File

@ -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"""
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}>
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div>
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="mt-1 text-4xl font-black">{gettext("Product analytics")}</h1>
<p class="mt-2 text-base-content/60">
{gettext(
<AdminComponents.shell
flash={@flash}
current_scope={@current_scope}
active={:analytics}
title={gettext("Product analytics")}
description={
gettext(
"Daily aggregate counters only. No user ID, email, coordinate, request text, chat text, medicine name, or device credential is stored here."
)}
</p>
</div>
<.link navigate={~p"/moderation"} class="btn btn-outline btn-sm">
{gettext("Back to moderation")}
</.link>
</div>
<div class="mt-8 overflow-x-auto rounded-3xl border border-base-300">
)
}
>
<div class="overflow-x-auto rounded-3xl border border-base-300 bg-base-100">
<table class="table">
<thead>
<tr>
@ -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")}
</button>
</Layouts.app>
</AdminComponents.shell>
"""
end
end

View File

@ -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"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div>
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="mt-1 text-4xl font-black">{gettext("Support operations")}</h1>
<p class="mt-2 text-base-content/60">
{gettext(
"Support requests and legal removal notices are separate queues. Decisions and operator identity are audited."
)}
</p>
</div>
<.link navigate={~p"/moderation"} class="btn btn-outline btn-sm">
{gettext("Trust and safety moderation")}
</.link>
</div>
<section class="mt-8">
<AdminComponents.shell
flash={@flash}
current_scope={@current_scope}
active={:support}
title={@operations_title}
description={@operations_description}
>
<section :if={@legal_allowed}>
<h2 class="text-2xl font-bold">{gettext("Content removal and TAKE IT DOWN")}</h2>
<.form
for={@legal_filter_form}
id="legal-case-filters"
phx-change="filter-legal"
class="mt-4 grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-4"
>
<.input
field={@legal_filter_form[:search]}
type="search"
label={gettext("Search legal cases")}
placeholder={gettext("Reference, email, URL, or explanation")}
phx-debounce="300"
/>
<.input
field={@legal_filter_form[:status]}
type="select"
label={gettext("Status")}
options={[
{gettext("Any status"), ""},
{gettext("Open"), "open"},
{gettext("Urgent review"), "urgent_review"},
{gettext("Reviewing"), "reviewing"},
{gettext("More information needed"), "needs_information"},
{gettext("Action taken"), "actioned"},
{gettext("Rejected"), "rejected"},
{gettext("Closed"), "closed"}
]}
/>
<.input
field={@legal_filter_form[:regime]}
type="select"
label={gettext("Regime")}
options={[
{gettext("Any regime"), ""},
{gettext("General"), "general"},
{gettext("DSA"), "dsa"},
{gettext("TAKE IT DOWN"), "take_it_down"}
]}
/>
<.input
field={@legal_filter_form[:assigned_to_id]}
type="select"
label={gettext("Assignee")}
options={assignee_options(@legal_assignees)}
/>
</.form>
<div class="mt-4 space-y-4">
<article
:for={notice <- @removal_notices}
@ -163,6 +320,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
scoped_form(
%{
"status" => 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
<span :if={notice.response_due_at} class="badge badge-error badge-outline">
{gettext("review by %{time}", time: notice.response_due_at)}
</span>
<span class="badge badge-ghost">
{if notice.assigned_to,
do:
gettext("Assigned: %{name}",
name: notice.assigned_to.display_name || notice.assigned_to.email
),
else: gettext("Unassigned")}
</span>
</div>
<dl class="mt-4 grid gap-2 text-sm sm:grid-cols-[10rem_1fr]">
<dt class="font-semibold">{gettext("Submitter")}</dt>
@ -226,7 +392,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Audited update")}
</span>
</div>
<div class="grid gap-5 p-4 xl:grid-cols-[15rem_minmax(0,1fr)] xl:items-start">
<div class="grid gap-5 p-4 xl:grid-cols-[15rem_15rem_minmax(0,1fr)] xl:items-start">
<div class="min-w-0">
<.input
field={form[:status]}
@ -247,6 +413,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Choose the state that will appear in the case history.")}
</p>
</div>
<div class="min-w-0">
<.input
field={form[:assigned_to_id]}
type="select"
label={gettext("Assigned operator")}
options={assignment_options(@legal_assignees)}
/>
</div>
<div class="min-w-0">
<.input
field={form[:resolution_note]}
@ -307,14 +481,64 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
</button>
</section>
<section class="mt-12">
<section :if={@support_allowed}>
<h2 class="text-2xl font-bold">{gettext("General support and data requests")}</h2>
<.form
for={@support_filter_form}
id="support-case-filters"
phx-change="filter-support"
class="mt-4 grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-4"
>
<.input
field={@support_filter_form[:search]}
type="search"
label={gettext("Search support cases")}
placeholder={gettext("Reference, email, subject, or details")}
phx-debounce="300"
/>
<.input
field={@support_filter_form[:status]}
type="select"
label={gettext("Status")}
options={[
{gettext("Any status"), ""},
{gettext("Open"), "open"},
{gettext("Reviewing"), "reviewing"},
{gettext("Waiting for requester"), "waiting_for_requester"},
{gettext("Resolved"), "resolved"},
{gettext("Closed"), "closed"}
]}
/>
<.input
field={@support_filter_form[:kind]}
type="select"
label={gettext("Request type")}
options={[
{gettext("Any type"), ""},
{gettext("Account access"), "account_access"},
{gettext("Technical issue"), "technical_issue"},
{gettext("Safety concern"), "safety_concern"},
{gettext("Moderation appeal"), "moderation_appeal"},
{gettext("Account deletion"), "account_deletion"},
{gettext("Data export"), "data_export"},
{gettext("Privacy request"), "privacy_request"},
{gettext("Other"), "other"}
]}
/>
<.input
field={@support_filter_form[:assigned_to_id]}
type="select"
label={gettext("Assignee")}
options={assignee_options(@support_assignees)}
/>
</.form>
<div class="mt-4 space-y-4">
<article :for={request <- @support_requests} class="rounded-2xl border border-base-300 p-5">
<% 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")}
</span>
<span class="text-xs text-base-content/65">{support_kind(request.kind)}</span>
<span class="badge badge-ghost">
{if request.assigned_to,
do:
gettext("Assigned: %{name}",
name: request.assigned_to.display_name || request.assigned_to.email
),
else: gettext("Unassigned")}
</span>
</div>
<h3 class="mt-3 font-bold">{request.subject}</h3>
<p class="mt-1 text-xs text-base-content/65">{request.contact_email}</p>
@ -427,7 +659,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Audited update")}
</span>
</div>
<div class="grid gap-5 p-4 xl:grid-cols-[15rem_minmax(0,1fr)] xl:items-start">
<div class="grid gap-5 p-4 xl:grid-cols-[15rem_15rem_minmax(0,1fr)] xl:items-start">
<div class="min-w-0">
<.input
field={form[:status]}
@ -446,6 +678,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Choose the state that will appear in the case history.")}
</p>
</div>
<div class="min-w-0">
<.input
field={form[:assigned_to_id]}
type="select"
label={gettext("Assigned operator")}
options={assignment_options(@support_assignees)}
/>
</div>
<div class="min-w-0">
<.input
field={form[:response]}
@ -505,7 +745,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
{gettext("Load more")}
</button>
</section>
</Layouts.app>
</AdminComponents.shell>
"""
end
end

View File

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

View File

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

View File

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

View File

@ -7,7 +7,9 @@ defmodule WhoNeedHelp.AccountsFixtures do
import Ecto.Query
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)

View File

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

View File

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

View File

@ -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}] =

View File

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

View File

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

View File

@ -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, %{

View File

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

View File

@ -33,10 +33,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"details" => "Review this request and keep the report private."
})
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", %{
test "moderator workspace renders reports, signals, and proposals without account management",
%{
conn: conn,
user: user
} do
user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!()
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