Add permission-scoped staff administration workspace
This commit is contained in:
parent
a540165da4
commit
e558486256
11
README.md
11
README.md
|
|
@ -66,7 +66,10 @@ local Codex CLI authenticated with their ChatGPT subscription.
|
||||||
disables rejected device registrations.
|
disables rejected device registrations.
|
||||||
- Bidirectional discovery blocks, scoped reports, account/request/category
|
- Bidirectional discovery blocks, scoped reports, account/request/category
|
||||||
moderation, abuse-signal review, and audited moderator access to only the
|
moderation, abuse-signal review, and audited moderator access to only the
|
||||||
conversation linked by a report.
|
conversation linked by a report. A unified staff workspace uses combinable
|
||||||
|
support, moderator, legal, analyst, and administrator roles; administrators
|
||||||
|
manage users and staff access while the last active administrator is
|
||||||
|
protected.
|
||||||
- Separate public support and content-removal intake, including moderation
|
- Separate public support and content-removal intake, including moderation
|
||||||
appeals, account deletion/data requests, a URL-only TAKE IT DOWN form,
|
appeals, account deletion/data requests, a URL-only TAKE IT DOWN form,
|
||||||
verified-contact status links, verification-gated support alerts, and audited
|
verified-contact status links, verification-gated support alerts, and audited
|
||||||
|
|
@ -590,8 +593,10 @@ Register and confirm the first account, then explicitly bootstrap it:
|
||||||
```
|
```
|
||||||
|
|
||||||
This succeeds only while no administrator exists and writes an audit event.
|
This succeeds only while no administrator exists and writes an audit event.
|
||||||
After bootstrap, an administrator can manage roles in `/moderation`; the last
|
After bootstrap, an administrator can manage multiple staff roles in
|
||||||
administrator cannot demote themselves. For kind, append `kind`:
|
`/admin/users`; the last active administrator cannot remove their own admin
|
||||||
|
access or be restricted. The complete role matrix and operator workflow are in
|
||||||
|
[`docs/staff-operations.md`](docs/staff-operations.md). For kind, append `kind`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/bootstrap-admin.sh you@example.com --confirm kind
|
./scripts/bootstrap-admin.sh you@example.com --confirm kind
|
||||||
|
|
|
||||||
81
docs/staff-operations.md
Normal file
81
docs/staff-operations.md
Normal 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.
|
||||||
|
|
@ -17,9 +17,13 @@ Who Need Help deliberately separates three mechanisms:
|
||||||
infringe a right. TAKE IT DOWN notices have a dedicated public form and a
|
infringe a right. TAKE IT DOWN notices have a dedicated public form and a
|
||||||
separate regime value in the removal queue.
|
separate regime value in the removal queue.
|
||||||
|
|
||||||
The operator workspace is `/support/operations`. It is protected by the same
|
The operator workspace is `/support/operations`. Support and legal access are
|
||||||
database-checked moderator or administrator authorization as the existing
|
separate database-checked permissions that can be combined on one account;
|
||||||
moderation workspace. Every creation and decision records an audit event.
|
administrators have both. Each queue supports search, status/type filtering,
|
||||||
|
assignee filtering, cursor pagination, and assignment only to an active member
|
||||||
|
of the corresponding team. Every creation and operator decision records an
|
||||||
|
audit event. The complete role matrix is documented in
|
||||||
|
`docs/staff-operations.md`.
|
||||||
|
|
||||||
## Public routes
|
## Public routes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,8 +122,10 @@ does not reveal its private chat. Only a report targeting a specific Activity
|
||||||
message permits the moderator to load that Activity's group conversation, and
|
message permits the moderator to load that Activity's group conversation, and
|
||||||
the evidence access audit records the Activity and message count.
|
the evidence access audit records the Activity and message count.
|
||||||
|
|
||||||
Report, request, signal, category, role, and account moderation are role
|
Report, request, signal, category, role, and account moderation are protected by
|
||||||
protected. The local Codex
|
the multi-role permission matrix described in `docs/staff-operations.md`.
|
||||||
|
Support, moderation, legal, and analytics access can be combined on one
|
||||||
|
account; administrators receive every permission. The local Codex
|
||||||
batch receives proposal text and aggregate vote counts only. It receives no
|
batch receives proposal text and aggregate vote counts only. It receives no
|
||||||
private messages, email addresses, OAuth tokens, exact coordinates, or raw
|
private messages, email addresses, OAuth tokens, exact coordinates, or raw
|
||||||
tracking routes.
|
tracking routes.
|
||||||
|
|
@ -142,7 +144,8 @@ Reports have `open`, `reviewing`, `resolved`, and `dismissed` states. A severe
|
||||||
report can hide a request and temporarily restrict an account pending review.
|
report can hide a request and temporarily restrict an account pending review.
|
||||||
Suspension invalidates that account's login sessions. The first administrator
|
Suspension invalidates that account's login sessions. The first administrator
|
||||||
requires an explicit one-time operational bootstrap, and later role changes are
|
requires an explicit one-time operational bootstrap, and later role changes are
|
||||||
audited; the last administrator cannot demote themselves.
|
audited; the last active administrator cannot remove their own admin access or
|
||||||
|
be restricted.
|
||||||
The operator must publish jurisdiction-specific emergency contacts, privacy
|
The operator must publish jurisdiction-specific emergency contacts, privacy
|
||||||
notice, prohibited-items policy, and data-retention policy before public launch.
|
notice, prohibited-items policy, and data-retention policy before public launch.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias Oban.Job
|
alias Oban.Job
|
||||||
alias WhoNeedHelp.Accounts.{SocialIdentity, User}
|
alias WhoNeedHelp.Accounts.{SocialIdentity, StaffRoleAssignment, User}
|
||||||
alias WhoNeedHelp.Activities.{Activity, Participant}
|
alias WhoNeedHelp.Activities.{Activity, Participant}
|
||||||
alias WhoNeedHelp.Activities.Message, as: ActivityMessage
|
alias WhoNeedHelp.Activities.Message, as: ActivityMessage
|
||||||
alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote}
|
alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote}
|
||||||
|
|
@ -94,17 +94,20 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
|
||||||
{:ok, users} =
|
{:ok, users} =
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
Map.new(@precreated_roles, fn role ->
|
Map.new(@precreated_roles, fn role ->
|
||||||
user_role = if role == "admin", do: :admin, else: :user
|
|
||||||
|
|
||||||
user =
|
user =
|
||||||
insert_user!(
|
insert_user!(
|
||||||
context.emails[role],
|
context.emails[role],
|
||||||
display_name(role),
|
display_name(role),
|
||||||
password_hash,
|
password_hash,
|
||||||
user_role,
|
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if role == "admin" do
|
||||||
|
%StaffRoleAssignment{}
|
||||||
|
|> StaffRoleAssignment.changeset(%{user_id: user.id, role: :admin})
|
||||||
|
|> Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
{role, %{"id" => user.id, "email" => user.email}}
|
{role, %{"id" => user.id, "email" => user.email}}
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
@ -500,7 +503,7 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
|
||||||
|
|
||||||
defp delete_count(query), do: query |> Repo.delete_all() |> elem(0)
|
defp delete_count(query), do: query |> Repo.delete_all() |> elem(0)
|
||||||
|
|
||||||
defp insert_user!(email, display_name, password_hash, role, now) do
|
defp insert_user!(email, display_name, password_hash, now) do
|
||||||
%User{}
|
%User{}
|
||||||
|> User.registration_changeset(%{
|
|> User.registration_changeset(%{
|
||||||
"email" => email,
|
"email" => email,
|
||||||
|
|
@ -510,7 +513,6 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
|
||||||
})
|
})
|
||||||
|> Ecto.Changeset.put_change(:hashed_password, password_hash)
|
|> Ecto.Changeset.put_change(:hashed_password, password_hash)
|
||||||
|> Ecto.Changeset.put_change(:confirmed_at, now)
|
|> Ecto.Changeset.put_change(:confirmed_at, now)
|
||||||
|> Ecto.Changeset.put_change(:role, role)
|
|
||||||
|> Repo.insert!()
|
|> Repo.insert!()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
AuthIdentity,
|
AuthIdentity,
|
||||||
Scope,
|
Scope,
|
||||||
SocialIdentity,
|
SocialIdentity,
|
||||||
|
StaffPermissions,
|
||||||
|
StaffRoleAssignment,
|
||||||
User,
|
User,
|
||||||
UserNotifier,
|
UserNotifier,
|
||||||
UserToken
|
UserToken
|
||||||
|
|
@ -154,46 +156,85 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def moderator?(%User{role: role}), do: role in [:moderator, :admin]
|
def staff_roles(%User{id: id}) do
|
||||||
def moderator?(_user), do: false
|
StaffRoleAssignment
|
||||||
|
|> where([assignment], assignment.user_id == ^id)
|
||||||
|
|> order_by([assignment], asc: assignment.role)
|
||||||
|
|> select([assignment], assignment.role)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
def moderator_authorized?(%User{id: id, role: role})
|
def loaded_staff_roles(%User{staff_role_assignments: %Ecto.Association.NotLoaded{}}), do: []
|
||||||
when role in [:moderator, :admin],
|
|
||||||
do:
|
def loaded_staff_roles(%User{staff_role_assignments: assignments}) when is_list(assignments),
|
||||||
|
do: Enum.map(assignments, & &1.role)
|
||||||
|
|
||||||
|
def loaded_staff_roles(_user), do: []
|
||||||
|
|
||||||
|
def permission?(%User{moderation_status: :active} = user, permission) do
|
||||||
|
StaffPermissions.allowed?(loaded_staff_roles(user), permission)
|
||||||
|
end
|
||||||
|
|
||||||
|
def permission?(_user, _permission), do: false
|
||||||
|
|
||||||
|
def authorized?(%User{id: id}, permission) do
|
||||||
|
allowed_roles = StaffPermissions.roles_for_permission(permission)
|
||||||
|
|
||||||
|
allowed_roles != [] and
|
||||||
|
Repo.exists?(
|
||||||
|
from assignment in StaffRoleAssignment,
|
||||||
|
join: user in User,
|
||||||
|
on: user.id == assignment.user_id,
|
||||||
|
where:
|
||||||
|
assignment.user_id == ^id and assignment.role in ^allowed_roles and
|
||||||
|
user.moderation_status == :active
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorized?(_user, _permission), do: false
|
||||||
|
|
||||||
|
def authorized_user_id?(user_id, permission) do
|
||||||
|
with {:ok, user_id} <- cast_id(user_id) do
|
||||||
|
allowed_roles = StaffPermissions.roles_for_permission(permission)
|
||||||
|
|
||||||
|
allowed_roles != [] and
|
||||||
Repo.exists?(
|
Repo.exists?(
|
||||||
from user in User,
|
from assignment in StaffRoleAssignment,
|
||||||
|
join: user in User,
|
||||||
|
on: user.id == assignment.user_id,
|
||||||
where:
|
where:
|
||||||
user.id == ^id and user.role in [:moderator, :admin] and
|
assignment.user_id == ^user_id and assignment.role in ^allowed_roles and
|
||||||
user.moderation_status == :active
|
user.moderation_status == :active
|
||||||
)
|
)
|
||||||
|
else
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def moderator_authorized?(_user), do: false
|
def staff?(user), do: permission?(user, :staff_access)
|
||||||
|
def staff_authorized?(user), do: authorized?(user, :staff_access)
|
||||||
def admin?(%User{role: :admin}), do: true
|
def moderator?(user), do: permission?(user, :moderation_view)
|
||||||
def admin?(_user), do: false
|
def moderator_authorized?(user), do: authorized?(user, :moderation_view)
|
||||||
|
def admin?(user), do: :admin in loaded_staff_roles(user)
|
||||||
def admin_authorized?(%User{id: id, role: :admin}),
|
def admin_authorized?(user), do: authorized?(user, :staff_manage)
|
||||||
do:
|
|
||||||
Repo.exists?(
|
|
||||||
from user in User,
|
|
||||||
where: user.id == ^id and user.role == :admin and user.moderation_status == :active
|
|
||||||
)
|
|
||||||
|
|
||||||
def admin_authorized?(_user), do: false
|
|
||||||
|
|
||||||
def list_users_for_moderation(%Scope{user: user}) do
|
def list_users_for_moderation(%Scope{user: user}) do
|
||||||
paginate_users_for_moderation(%Scope{user: user}).entries
|
paginate_users_for_moderation(%Scope{user: user}).entries
|
||||||
end
|
end
|
||||||
|
|
||||||
def paginate_users_for_moderation(%Scope{user: user}, options \\ []) do
|
def paginate_users_for_moderation(%Scope{user: user}, options \\ []) do
|
||||||
if moderator_authorized?(user) do
|
if authorized?(user, :users_view) do
|
||||||
limit = Pagination.limit(options)
|
limit = Pagination.limit(options)
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
User
|
User
|
||||||
|
|> maybe_user_status(Keyword.get(options, :status))
|
||||||
|
|> maybe_user_role(Keyword.get(options, :role))
|
||||||
|
|> maybe_user_search(Keyword.get(options, :search))
|
||||||
|> before_moderation_user(cursor)
|
|> before_moderation_user(cursor)
|
||||||
|> order_by([user], desc: user.inserted_at, desc: user.id)
|
|> order_by([user], desc: user.inserted_at, desc: user.id)
|
||||||
|> limit(^(limit + 1))
|
|> limit(^(limit + 1))
|
||||||
|
|> preload(:staff_role_assignments)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
else
|
else
|
||||||
|
|
@ -203,7 +244,7 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
|
|
||||||
def moderate_user(%User{} = moderator, user_id, attrs) do
|
def moderate_user(%User{} = moderator, user_id, attrs) do
|
||||||
with {:ok, user_id} <- cast_id(user_id),
|
with {:ok, user_id} <- cast_id(user_id),
|
||||||
true <- moderator_authorized?(moderator) do
|
true <- authorized?(moderator, :users_moderate) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
active_admins = lock_active_admins()
|
active_admins = lock_active_admins()
|
||||||
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
|
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
|
||||||
|
|
@ -212,14 +253,14 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
requested_status = attrs["moderation_status"] || attrs[:moderation_status]
|
requested_status = attrs["moderation_status"] || attrs[:moderation_status]
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
user.role in [:moderator, :admin] and not admin_authorized?(moderator) ->
|
user_has_staff_roles?(user.id) and not admin_authorized?(moderator) ->
|
||||||
{:error, :forbidden}
|
{:error, :forbidden}
|
||||||
|
|
||||||
user.id == moderator.id and
|
user.id == moderator.id and
|
||||||
requested_status in [:restricted, :suspended, "restricted", "suspended"] ->
|
requested_status in [:restricted, :suspended, "restricted", "suspended"] ->
|
||||||
{:error, :cannot_restrict_self}
|
{:error, :cannot_restrict_self}
|
||||||
|
|
||||||
user.role == :admin and user.moderation_status == :active and
|
user_has_role?(user.id, :admin) and user.moderation_status == :active and
|
||||||
requested_status not in [:active, "active"] and length(active_admins) == 1 ->
|
requested_status not in [:active, "active"] and length(active_admins) == 1 ->
|
||||||
{:error, :last_admin}
|
{:error, :last_admin}
|
||||||
|
|
||||||
|
|
@ -252,21 +293,54 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def change_user_role(%User{} = admin, user_id, attrs) do
|
def set_staff_roles(%User{} = admin, user_id, roles) when is_list(roles) do
|
||||||
with {:ok, user_id} <- cast_id(user_id),
|
with {:ok, user_id} <- cast_id(user_id),
|
||||||
true <- admin_authorized?(admin) do
|
true <- admin_authorized?(admin),
|
||||||
|
true <- sudo_mode?(admin, -10),
|
||||||
|
{:ok, roles} <- normalize_staff_roles(roles) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
active_admins = lock_active_admins()
|
active_admins = lock_active_admins()
|
||||||
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
|
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
|
||||||
|
|
||||||
if user do
|
if user do
|
||||||
requested_role = attrs["role"] || attrs[:role]
|
current_roles = staff_roles(user)
|
||||||
|
|
||||||
if user.role == :admin and user.moderation_status == :active and
|
if :admin in current_roles and user.moderation_status == :active and
|
||||||
requested_role not in [:admin, "admin"] and length(active_admins) == 1 do
|
:admin not in roles and length(active_admins) == 1 do
|
||||||
{:error, :last_admin}
|
{:error, :last_admin}
|
||||||
else
|
else
|
||||||
user |> User.role_changeset(attrs) |> Repo.update()
|
if roles == [] do
|
||||||
|
Repo.delete_all(
|
||||||
|
from assignment in StaffRoleAssignment,
|
||||||
|
where: assignment.user_id == ^user.id
|
||||||
|
)
|
||||||
|
else
|
||||||
|
StaffRoleAssignment
|
||||||
|
|> where(
|
||||||
|
[assignment],
|
||||||
|
assignment.user_id == ^user.id and assignment.role not in ^roles
|
||||||
|
)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
end
|
||||||
|
|
||||||
|
roles
|
||||||
|
|> Enum.reject(&(&1 in current_roles))
|
||||||
|
|> Enum.each(fn role ->
|
||||||
|
%StaffRoleAssignment{}
|
||||||
|
|> StaffRoleAssignment.changeset(%{
|
||||||
|
user_id: user.id,
|
||||||
|
role: role,
|
||||||
|
assigned_by_id: admin.id
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
end)
|
||||||
|
|
||||||
|
session_tokens =
|
||||||
|
UserToken
|
||||||
|
|> where([token], token.user_id == ^user.id and token.context == "session")
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
{:ok, %{user: preload_staff_roles(user), session_tokens: session_tokens}}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
|
|
@ -275,17 +349,76 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
else
|
else
|
||||||
false -> {:error, :forbidden}
|
false -> {:error, :forbidden}
|
||||||
{:error, :not_found} = error -> error
|
{:error, :not_found} = error -> error
|
||||||
|
{:error, :invalid_roles} = error -> error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_staff_roles(_admin, _user_id, _roles), do: {:error, :invalid_roles}
|
||||||
|
|
||||||
defp lock_active_admins do
|
defp lock_active_admins do
|
||||||
User
|
active_user_ids =
|
||||||
|> where([user], user.role == :admin and user.moderation_status == :active)
|
from user in User,
|
||||||
|> order_by([user], asc: user.id)
|
where: user.moderation_status == :active,
|
||||||
|
select: user.id
|
||||||
|
|
||||||
|
StaffRoleAssignment
|
||||||
|
|> where(
|
||||||
|
[assignment],
|
||||||
|
assignment.role == :admin and assignment.user_id in subquery(active_user_ids)
|
||||||
|
)
|
||||||
|
|> order_by([assignment], asc: assignment.user_id)
|
||||||
|> lock("FOR UPDATE")
|
|> lock("FOR UPDATE")
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def list_staff_for_permission(permission) do
|
||||||
|
allowed_roles = StaffPermissions.roles_for_permission(permission)
|
||||||
|
|
||||||
|
User
|
||||||
|
|> join(:inner, [user], assignment in StaffRoleAssignment,
|
||||||
|
on: assignment.user_id == user.id and assignment.role in ^allowed_roles
|
||||||
|
)
|
||||||
|
|> where([user], user.moderation_status == :active)
|
||||||
|
|> order_by([user], asc: user.display_name, asc: user.email)
|
||||||
|
|> distinct(true)
|
||||||
|
|> preload(:staff_role_assignments)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
def preload_staff_roles(%User{} = user),
|
||||||
|
do: Repo.preload(user, :staff_role_assignments, force: true)
|
||||||
|
|
||||||
|
defp user_has_staff_roles?(user_id) do
|
||||||
|
Repo.exists?(from assignment in StaffRoleAssignment, where: assignment.user_id == ^user_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp user_has_role?(user_id, role) do
|
||||||
|
Repo.exists?(
|
||||||
|
from assignment in StaffRoleAssignment,
|
||||||
|
where: assignment.user_id == ^user_id and assignment.role == ^role
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_staff_roles(roles) do
|
||||||
|
roles =
|
||||||
|
roles
|
||||||
|
|> Enum.map(fn
|
||||||
|
role when is_atom(role) ->
|
||||||
|
role
|
||||||
|
|
||||||
|
role when is_binary(role) ->
|
||||||
|
Enum.find(StaffRoleAssignment.roles(), &(Atom.to_string(&1) == role))
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
nil
|
||||||
|
end)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|
||||||
|
if Enum.all?(roles, &StaffPermissions.valid_role?/1),
|
||||||
|
do: {:ok, Enum.sort(roles)},
|
||||||
|
else: {:error, :invalid_roles}
|
||||||
|
end
|
||||||
|
|
||||||
## User registration
|
## User registration
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -809,7 +942,11 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
"""
|
"""
|
||||||
def get_user_by_session_token(token) do
|
def get_user_by_session_token(token) do
|
||||||
{:ok, query} = UserToken.verify_session_token_query(token)
|
{:ok, query} = UserToken.verify_session_token_query(token)
|
||||||
Repo.one(query)
|
|
||||||
|
case Repo.one(query) do
|
||||||
|
{%User{} = user, inserted_at} -> {preload_staff_roles(user), inserted_at}
|
||||||
|
nil -> nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -967,6 +1104,49 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_user_status(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_user_status(query, value) do
|
||||||
|
case Ecto.Enum.cast_value(User, :moderation_status, value) do
|
||||||
|
{:ok, status} -> where(query, [user], user.moderation_status == ^status)
|
||||||
|
:error -> where(query, [user], false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_user_role(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_user_role(query, "user") do
|
||||||
|
from user in query,
|
||||||
|
left_join: assignment in StaffRoleAssignment,
|
||||||
|
on: assignment.user_id == user.id,
|
||||||
|
where: is_nil(assignment.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_user_role(query, value) do
|
||||||
|
case Enum.find(StaffPermissions.roles(), &(Atom.to_string(&1) == to_string(value))) do
|
||||||
|
nil ->
|
||||||
|
where(query, [user], false)
|
||||||
|
|
||||||
|
role ->
|
||||||
|
from user in query,
|
||||||
|
join: assignment in StaffRoleAssignment,
|
||||||
|
on: assignment.user_id == user.id and assignment.role == ^role
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_user_search(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_user_search(query, value) do
|
||||||
|
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
||||||
|
pattern = "%#{term}%"
|
||||||
|
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[user],
|
||||||
|
ilike(user.email, ^pattern) or ilike(user.display_name, ^pattern)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp cast_id(value) do
|
defp cast_id(value) do
|
||||||
case Ecto.UUID.cast(value) do
|
case Ecto.UUID.cast(value) do
|
||||||
{:ok, id} -> {:ok, id}
|
{:ok, id} -> {:ok, id}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Accounts.DataExport do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Accounts
|
||||||
alias WhoNeedHelp.Accounts.{AuthIdentity, Scope, SocialIdentity, User}
|
alias WhoNeedHelp.Accounts.{AuthIdentity, Scope, SocialIdentity, User}
|
||||||
alias WhoNeedHelp.Activities.{Activity, Message, Participant}
|
alias WhoNeedHelp.Activities.{Activity, Message, Participant}
|
||||||
alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote}
|
alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote}
|
||||||
|
|
@ -31,7 +32,8 @@ defmodule WhoNeedHelp.Accounts.DataExport do
|
||||||
"version" => @version,
|
"version" => @version,
|
||||||
"exported_at" => exported_at,
|
"exported_at" => exported_at,
|
||||||
"account" =>
|
"account" =>
|
||||||
record(user, [
|
user
|
||||||
|
|> record([
|
||||||
:id,
|
:id,
|
||||||
:email,
|
:email,
|
||||||
:display_name,
|
:display_name,
|
||||||
|
|
@ -39,14 +41,14 @@ defmodule WhoNeedHelp.Accounts.DataExport do
|
||||||
:locale,
|
:locale,
|
||||||
:location_visibility,
|
:location_visibility,
|
||||||
:direct_message_policy,
|
:direct_message_policy,
|
||||||
:role,
|
|
||||||
:moderation_status,
|
:moderation_status,
|
||||||
:tip_url,
|
:tip_url,
|
||||||
:confirmed_at,
|
:confirmed_at,
|
||||||
:accepted_terms_at,
|
:accepted_terms_at,
|
||||||
:inserted_at,
|
:inserted_at,
|
||||||
:updated_at
|
:updated_at
|
||||||
]),
|
])
|
||||||
|
|> Map.put(:staff_roles, Accounts.staff_roles(user)),
|
||||||
"authentication_identities" =>
|
"authentication_identities" =>
|
||||||
owned(AuthIdentity, :user_id, user_id, [
|
owned(AuthIdentity, :user_id, user_id, [
|
||||||
:id,
|
:id,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ defmodule WhoNeedHelp.Accounts.DataLifecycle do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Accounts
|
||||||
alias WhoNeedHelp.Accounts.{Scope, User}
|
alias WhoNeedHelp.Accounts.{Scope, User}
|
||||||
alias WhoNeedHelp.Activities.{Activity, Participant}
|
alias WhoNeedHelp.Activities.{Activity, Participant}
|
||||||
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
||||||
|
|
@ -45,7 +46,7 @@ defmodule WhoNeedHelp.Accounts.DataLifecycle do
|
||||||
case_id: request.id,
|
case_id: request.id,
|
||||||
reference: request.reference,
|
reference: request.reference,
|
||||||
account_id: user.id,
|
account_id: user.id,
|
||||||
account_role: user.role,
|
account_staff_roles: Accounts.staff_roles(user),
|
||||||
contact_verified: true,
|
contact_verified: true,
|
||||||
assessed_by: moderator.id,
|
assessed_by: moderator.id,
|
||||||
assessed_at: DateTime.utc_now(:second),
|
assessed_at: DateTime.utc_now(:second),
|
||||||
|
|
|
||||||
70
lib/who_need_help/accounts/staff_permissions.ex
Normal file
70
lib/who_need_help/accounts/staff_permissions.ex
Normal 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
|
||||||
28
lib/who_need_help/accounts/staff_role_assignment.ex
Normal file
28
lib/who_need_help/accounts/staff_role_assignment.ex
Normal 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
|
||||||
|
|
@ -24,8 +24,6 @@ defmodule WhoNeedHelp.Accounts.User do
|
||||||
values: [:everyone, :verified_accounts, :completed_help_users, :nobody],
|
values: [:everyone, :verified_accounts, :completed_help_users, :nobody],
|
||||||
default: :verified_accounts
|
default: :verified_accounts
|
||||||
|
|
||||||
field :role, Ecto.Enum, values: [:user, :moderator, :admin], default: :user
|
|
||||||
|
|
||||||
field :moderation_status, Ecto.Enum,
|
field :moderation_status, Ecto.Enum,
|
||||||
values: [:active, :restricted, :suspended],
|
values: [:active, :restricted, :suspended],
|
||||||
default: :active
|
default: :active
|
||||||
|
|
@ -40,6 +38,9 @@ defmodule WhoNeedHelp.Accounts.User do
|
||||||
has_many :nearby_subscriptions, WhoNeedHelp.Notifications.NearbySubscription
|
has_many :nearby_subscriptions, WhoNeedHelp.Notifications.NearbySubscription
|
||||||
has_many :push_devices, WhoNeedHelp.Notifications.PushDevice
|
has_many :push_devices, WhoNeedHelp.Notifications.PushDevice
|
||||||
|
|
||||||
|
has_many :staff_role_assignments, WhoNeedHelp.Accounts.StaffRoleAssignment,
|
||||||
|
preload_order: [asc: :role]
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -115,12 +116,6 @@ defmodule WhoNeedHelp.Accounts.User do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def role_changeset(user, attrs) do
|
|
||||||
user
|
|
||||||
|> cast(attrs, [:role])
|
|
||||||
|> validate_required([:role])
|
|
||||||
end
|
|
||||||
|
|
||||||
defp validate_url(changeset, field) do
|
defp validate_url(changeset, field) do
|
||||||
validate_change(changeset, field, fn ^field, value ->
|
validate_change(changeset, field, fn ^field, value ->
|
||||||
case URI.parse(value) do
|
case URI.parse(value) do
|
||||||
|
|
|
||||||
63
lib/who_need_help/admin.ex
Normal file
63
lib/who_need_help/admin.ex
Normal 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
|
||||||
|
|
@ -185,7 +185,7 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
end
|
end
|
||||||
|
|
||||||
def paginate_proposals_for_moderation(%Scope{user: user}, options \\ []) do
|
def paginate_proposals_for_moderation(%Scope{user: user}, options \\ []) do
|
||||||
if Accounts.moderator_authorized?(user) do
|
if Accounts.authorized?(user, :categories_manage) do
|
||||||
limit = Pagination.limit(options)
|
limit = Pagination.limit(options)
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
|
|
@ -204,7 +204,7 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
|
|
||||||
def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do
|
def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do
|
||||||
with {:ok, proposal_id} <- cast_id(proposal_id),
|
with {:ok, proposal_id} <- cast_id(proposal_id),
|
||||||
true <- Accounts.moderator_authorized?(moderator) do
|
true <- Accounts.authorized?(moderator, :categories_manage) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
proposal = locked_proposal(proposal_id)
|
proposal = locked_proposal(proposal_id)
|
||||||
|
|
||||||
|
|
@ -267,7 +267,7 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
|
|
||||||
defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do
|
defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do
|
||||||
with {:ok, proposal_id} <- cast_id(proposal_id),
|
with {:ok, proposal_id} <- cast_id(proposal_id),
|
||||||
true <- Accounts.moderator_authorized?(moderator) do
|
true <- Accounts.authorized?(moderator, :categories_manage) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
proposal = locked_proposal(proposal_id)
|
proposal = locked_proposal(proposal_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,16 +106,19 @@ defmodule WhoNeedHelp.ContentRemoval do
|
||||||
end
|
end
|
||||||
|
|
||||||
def paginate_for_staff(%Scope{user: user}, options \\ []) do
|
def paginate_for_staff(%Scope{user: user}, options \\ []) do
|
||||||
if Accounts.moderator_authorized?(user) do
|
if Accounts.authorized?(user, :legal_view) do
|
||||||
limit = Pagination.limit(options)
|
limit = Pagination.limit(options)
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
Notice
|
Notice
|
||||||
|> maybe_regime(Keyword.get(options, :regime))
|
|> maybe_regime(Keyword.get(options, :regime))
|
||||||
|
|> maybe_status(Keyword.get(options, :status))
|
||||||
|
|> maybe_assignee(Keyword.get(options, :assigned_to_id), user.id)
|
||||||
|
|> maybe_search(Keyword.get(options, :search))
|
||||||
|> before(cursor)
|
|> before(cursor)
|
||||||
|> order_by([notice], desc: notice.inserted_at, desc: notice.id)
|
|> order_by([notice], desc: notice.inserted_at, desc: notice.id)
|
||||||
|> limit(^(limit + 1))
|
|> limit(^(limit + 1))
|
||||||
|> preload([:requester, :reviewed_by])
|
|> preload([:requester, :reviewed_by, :assigned_to])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
else
|
else
|
||||||
|
|
@ -125,41 +128,42 @@ defmodule WhoNeedHelp.ContentRemoval do
|
||||||
|
|
||||||
def moderate(%Scope{user: moderator}, id, attrs) do
|
def moderate(%Scope{user: moderator}, id, attrs) do
|
||||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||||
true <- Accounts.moderator_authorized?(moderator) do
|
true <- Accounts.authorized?(moderator, :legal_manage) do
|
||||||
attrs =
|
with {:ok, attrs} <- normalize_assignment(normalize_keys(attrs), :legal_manage) do
|
||||||
attrs
|
attrs =
|
||||||
|> normalize_keys()
|
Map.merge(attrs, %{
|
||||||
|> Map.merge(%{
|
"reviewed_at" => DateTime.utc_now(:second),
|
||||||
"reviewed_at" => DateTime.utc_now(:second),
|
"reviewed_by_id" => moderator.id
|
||||||
"reviewed_by_id" => moderator.id
|
})
|
||||||
})
|
|
||||||
|
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
notice =
|
notice =
|
||||||
Notice
|
Notice
|
||||||
|> where([notice], notice.id == ^id)
|
|> where([notice], notice.id == ^id)
|
||||||
|> lock("FOR UPDATE")
|
|> lock("FOR UPDATE")
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
|
|
||||||
if notice do
|
if notice do
|
||||||
with {:ok, notice} <- notice |> Notice.moderation_changeset(attrs) |> Repo.update(),
|
with {:ok, notice} <- notice |> Notice.moderation_changeset(attrs) |> Repo.update(),
|
||||||
{:ok, _audit} <-
|
{:ok, _audit} <-
|
||||||
Trust.audit(
|
Trust.audit(
|
||||||
moderator.id,
|
moderator.id,
|
||||||
"content_removal_notice.moderated",
|
"content_removal_notice.moderated",
|
||||||
"content_removal_notice",
|
"content_removal_notice",
|
||||||
notice.id,
|
notice.id,
|
||||||
%{
|
%{
|
||||||
"status" => to_string(notice.status)
|
"status" => to_string(notice.status),
|
||||||
}
|
"assigned_to_id" => notice.assigned_to_id
|
||||||
) do
|
}
|
||||||
{:ok, notice}
|
) do
|
||||||
|
{:ok, Repo.preload(notice, :assigned_to, force: true)}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, :not_found}
|
||||||
end
|
end
|
||||||
else
|
end)
|
||||||
{:error, :not_found}
|
|> notify_decision()
|
||||||
end
|
end
|
||||||
end)
|
|
||||||
|> notify_decision()
|
|
||||||
else
|
else
|
||||||
false -> {:error, :forbidden}
|
false -> {:error, :forbidden}
|
||||||
_ -> {:error, :not_found}
|
_ -> {:error, :not_found}
|
||||||
|
|
@ -230,8 +234,54 @@ defmodule WhoNeedHelp.ContentRemoval do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_regime(query, nil), do: query
|
defp maybe_regime(query, nil), do: query
|
||||||
|
defp maybe_regime(query, ""), do: query
|
||||||
defp maybe_regime(query, regime), do: where(query, [notice], notice.regime == ^regime)
|
defp maybe_regime(query, regime), do: where(query, [notice], notice.regime == ^regime)
|
||||||
|
|
||||||
|
defp maybe_status(query, nil), do: query
|
||||||
|
defp maybe_status(query, ""), do: query
|
||||||
|
defp maybe_status(query, status), do: where(query, [notice], notice.status == ^status)
|
||||||
|
|
||||||
|
defp maybe_assignee(query, nil, _current_user_id), do: query
|
||||||
|
defp maybe_assignee(query, "", _current_user_id), do: query
|
||||||
|
|
||||||
|
defp maybe_assignee(query, "unassigned", _current_user_id),
|
||||||
|
do: where(query, [n], is_nil(n.assigned_to_id))
|
||||||
|
|
||||||
|
defp maybe_assignee(query, "mine", current_user_id),
|
||||||
|
do: where(query, [n], n.assigned_to_id == ^current_user_id)
|
||||||
|
|
||||||
|
defp maybe_assignee(query, assignee_id, _current_user_id),
|
||||||
|
do: where(query, [n], n.assigned_to_id == ^assignee_id)
|
||||||
|
|
||||||
|
defp maybe_search(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_search(query, value) do
|
||||||
|
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
||||||
|
pattern = "%#{term}%"
|
||||||
|
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[notice],
|
||||||
|
ilike(notice.reference, ^pattern) or ilike(notice.contact_email, ^pattern) or
|
||||||
|
ilike(notice.submitter_name, ^pattern)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_assignment(attrs, permission) do
|
||||||
|
case Map.fetch(attrs, "assigned_to_id") do
|
||||||
|
:error ->
|
||||||
|
{:ok, attrs}
|
||||||
|
|
||||||
|
{:ok, value} when value in [nil, ""] ->
|
||||||
|
{:ok, Map.put(attrs, "assigned_to_id", nil)}
|
||||||
|
|
||||||
|
{:ok, user_id} ->
|
||||||
|
if Accounts.authorized_user_id?(user_id, permission),
|
||||||
|
do: {:ok, attrs},
|
||||||
|
else: {:error, :invalid_assignee}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp maybe_use_user_email(attrs, %User{email: email}) do
|
defp maybe_use_user_email(attrs, %User{email: email}) do
|
||||||
Map.put(attrs, "contact_email", email)
|
Map.put(attrs, "contact_email", email)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ defmodule WhoNeedHelp.ContentRemoval.Notice do
|
||||||
field :decision_sent_at, :utc_datetime
|
field :decision_sent_at, :utc_datetime
|
||||||
belongs_to :requester, WhoNeedHelp.Accounts.User
|
belongs_to :requester, WhoNeedHelp.Accounts.User
|
||||||
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
|
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
|
||||||
|
belongs_to :assigned_to, WhoNeedHelp.Accounts.User
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -103,7 +104,14 @@ defmodule WhoNeedHelp.ContentRemoval.Notice do
|
||||||
|
|
||||||
def moderation_changeset(notice, attrs) do
|
def moderation_changeset(notice, attrs) do
|
||||||
notice
|
notice
|
||||||
|> cast(attrs, [:status, :resolution_note, :reviewed_at, :reviewed_by_id, :decision_sent_at])
|
|> cast(attrs, [
|
||||||
|
:status,
|
||||||
|
:resolution_note,
|
||||||
|
:reviewed_at,
|
||||||
|
:reviewed_by_id,
|
||||||
|
:decision_sent_at,
|
||||||
|
:assigned_to_id
|
||||||
|
])
|
||||||
|> validate_required([:status, :reviewed_at, :reviewed_by_id])
|
|> validate_required([:status, :reviewed_at, :reviewed_by_id])
|
||||||
|> validate_length(:resolution_note, max: 10_000)
|
|> validate_length(:resolution_note, max: 10_000)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -342,7 +342,7 @@ defmodule WhoNeedHelp.Help do
|
||||||
request.requester_id == user.id ->
|
request.requester_id == user.id ->
|
||||||
{:ok, request}
|
{:ok, request}
|
||||||
|
|
||||||
Accounts.moderator_authorized?(user) ->
|
Accounts.authorized?(user, :moderation_view) ->
|
||||||
{:ok, request}
|
{:ok, request}
|
||||||
|
|
||||||
not is_nil(request.hidden_at) ->
|
not is_nil(request.hidden_at) ->
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ defmodule WhoNeedHelp.ProductAnalytics do
|
||||||
def increment_for_user(_user, _metric, _dimension), do: {:error, :invalid_user}
|
def increment_for_user(_user, _metric, _dimension), do: {:error, :invalid_user}
|
||||||
|
|
||||||
def paginate(%Scope{user: user}, options \\ []) do
|
def paginate(%Scope{user: user}, options \\ []) do
|
||||||
if Accounts.moderator_authorized?(user) do
|
if Accounts.authorized?(user, :analytics_view) do
|
||||||
limit = Pagination.limit(options)
|
limit = Pagination.limit(options)
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@ defmodule WhoNeedHelp.Release do
|
||||||
|
|
||||||
admin_count =
|
admin_count =
|
||||||
WhoNeedHelp.Repo.aggregate(
|
WhoNeedHelp.Repo.aggregate(
|
||||||
from(user in WhoNeedHelp.Accounts.User, where: user.role == :admin),
|
from(assignment in WhoNeedHelp.Accounts.StaffRoleAssignment,
|
||||||
|
where: assignment.role == :admin
|
||||||
|
),
|
||||||
:count
|
:count
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -42,13 +44,16 @@ defmodule WhoNeedHelp.Release do
|
||||||
{:error, :admin_already_exists}
|
{:error, :admin_already_exists}
|
||||||
|
|
||||||
user = WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) ->
|
user = WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) ->
|
||||||
with {:ok, user} <-
|
with {:ok, _assignment} <-
|
||||||
user
|
%WhoNeedHelp.Accounts.StaffRoleAssignment{}
|
||||||
|> WhoNeedHelp.Accounts.User.role_changeset(%{role: :admin})
|
|> WhoNeedHelp.Accounts.StaffRoleAssignment.changeset(%{
|
||||||
|> WhoNeedHelp.Repo.update(),
|
user_id: user.id,
|
||||||
|
role: :admin
|
||||||
|
})
|
||||||
|
|> WhoNeedHelp.Repo.insert(),
|
||||||
{:ok, _audit} <-
|
{:ok, _audit} <-
|
||||||
WhoNeedHelp.Trust.audit(nil, "user.admin_bootstrapped", "user", user.id) do
|
WhoNeedHelp.Trust.audit(nil, "user.admin_bootstrapped", "user", user.id) do
|
||||||
{:ok, %{id: user.id, email: user.email, role: user.role}}
|
{:ok, %{id: user.id, email: user.email, roles: [:admin]}}
|
||||||
end
|
end
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
|
|
|
||||||
|
|
@ -141,19 +141,23 @@ defmodule WhoNeedHelp.Support do
|
||||||
end
|
end
|
||||||
|
|
||||||
def paginate_for_staff(%Scope{user: user}, options \\ []) do
|
def paginate_for_staff(%Scope{user: user}, options \\ []) do
|
||||||
if Accounts.moderator_authorized?(user) do
|
if Accounts.authorized?(user, :support_view) do
|
||||||
limit = Pagination.limit(options)
|
limit = Pagination.limit(options)
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
SupportRequest
|
SupportRequest
|
||||||
|> where([request], not is_nil(request.contact_verified_at))
|
|> where([request], not is_nil(request.contact_verified_at))
|
||||||
|> maybe_kind(Keyword.get(options, :kind))
|
|> maybe_kind(Keyword.get(options, :kind))
|
||||||
|
|> maybe_status(Keyword.get(options, :status))
|
||||||
|
|> maybe_assignee(Keyword.get(options, :assigned_to_id), user.id)
|
||||||
|
|> maybe_search(Keyword.get(options, :search))
|
||||||
|> before(cursor)
|
|> before(cursor)
|
||||||
|> order_by([request], desc: request.inserted_at, desc: request.id)
|
|> order_by([request], desc: request.inserted_at, desc: request.id)
|
||||||
|> limit(^(limit + 1))
|
|> limit(^(limit + 1))
|
||||||
|> preload([
|
|> preload([
|
||||||
:requester,
|
:requester,
|
||||||
:reviewed_by,
|
:reviewed_by,
|
||||||
|
:assigned_to,
|
||||||
conversation_messages: :sender,
|
conversation_messages: :sender,
|
||||||
status_events: :actor
|
status_events: :actor
|
||||||
])
|
])
|
||||||
|
|
@ -166,62 +170,65 @@ defmodule WhoNeedHelp.Support do
|
||||||
|
|
||||||
def moderate(%Scope{user: moderator}, id, attrs) do
|
def moderate(%Scope{user: moderator}, id, attrs) do
|
||||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||||
true <- Accounts.moderator_authorized?(moderator) do
|
true <- Accounts.authorized?(moderator, :support_manage) do
|
||||||
attrs = normalize_keys(attrs)
|
attrs = normalize_keys(attrs)
|
||||||
response = normalize_message(attrs["response"] || attrs["resolution_note"])
|
response = normalize_message(attrs["response"] || attrs["resolution_note"])
|
||||||
|
|
||||||
attrs =
|
with {:ok, attrs} <- normalize_assignment(attrs, :support_manage) do
|
||||||
attrs
|
attrs =
|
||||||
|> Map.delete("response")
|
attrs
|
||||||
|> maybe_put_resolution(response)
|
|> Map.delete("response")
|
||||||
|> Map.merge(%{
|
|> maybe_put_resolution(response)
|
||||||
"reviewed_at" => DateTime.utc_now(:second),
|
|> Map.merge(%{
|
||||||
"reviewed_by_id" => moderator.id
|
"reviewed_at" => DateTime.utc_now(:second),
|
||||||
})
|
"reviewed_by_id" => moderator.id
|
||||||
|
})
|
||||||
|
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
request =
|
request =
|
||||||
SupportRequest
|
SupportRequest
|
||||||
|> where([request], request.id == ^id and not is_nil(request.contact_verified_at))
|
|> where([request], request.id == ^id and not is_nil(request.contact_verified_at))
|
||||||
|> lock("FOR UPDATE")
|
|> lock("FOR UPDATE")
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
|
|
||||||
if request do
|
if request do
|
||||||
previous_status = request.status
|
previous_status = request.status
|
||||||
|
|
||||||
response_to_record =
|
response_to_record =
|
||||||
if response && response != request.resolution_note, do: response
|
if response && response != request.resolution_note, do: response
|
||||||
|
|
||||||
with {:ok, request} <-
|
with {:ok, request} <-
|
||||||
request |> SupportRequest.moderation_changeset(attrs) |> Repo.update(),
|
request |> SupportRequest.moderation_changeset(attrs) |> Repo.update(),
|
||||||
{:ok, _event} <-
|
{:ok, _event} <-
|
||||||
maybe_record_status_event(
|
maybe_record_status_event(
|
||||||
request,
|
request,
|
||||||
previous_status,
|
previous_status,
|
||||||
request.status,
|
request.status,
|
||||||
moderator.id,
|
moderator.id,
|
||||||
:staff
|
:staff
|
||||||
),
|
),
|
||||||
{:ok, _message} <-
|
{:ok, _message} <-
|
||||||
maybe_record_message(request, moderator.id, :staff, response_to_record),
|
maybe_record_message(request, moderator.id, :staff, response_to_record),
|
||||||
{:ok, _audit} <-
|
{:ok, _audit} <-
|
||||||
Trust.audit(
|
Trust.audit(
|
||||||
moderator.id,
|
moderator.id,
|
||||||
"support_request.moderated",
|
"support_request.moderated",
|
||||||
"support_request",
|
"support_request",
|
||||||
request.id,
|
request.id,
|
||||||
%{
|
%{
|
||||||
"status" => to_string(request.status)
|
"status" => to_string(request.status),
|
||||||
}
|
"assigned_to_id" => request.assigned_to_id
|
||||||
) do
|
}
|
||||||
{:ok, preload_conversation(request)}
|
) do
|
||||||
|
{:ok, preload_conversation(request)}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, :not_found}
|
||||||
end
|
end
|
||||||
else
|
end)
|
||||||
{:error, :not_found}
|
|> notify_decision()
|
||||||
end
|
|> broadcast_request_update()
|
||||||
end)
|
end
|
||||||
|> notify_decision()
|
|
||||||
|> broadcast_request_update()
|
|
||||||
else
|
else
|
||||||
false -> {:error, :forbidden}
|
false -> {:error, :forbidden}
|
||||||
_ -> {:error, :not_found}
|
_ -> {:error, :not_found}
|
||||||
|
|
@ -343,7 +350,7 @@ defmodule WhoNeedHelp.Support do
|
||||||
end
|
end
|
||||||
|
|
||||||
def deletion_assessment(%Scope{user: moderator} = scope, id) do
|
def deletion_assessment(%Scope{user: moderator} = scope, id) do
|
||||||
with true <- Accounts.moderator_authorized?(moderator),
|
with true <- Accounts.authorized?(moderator, :support_manage),
|
||||||
{:ok, id} <- Ecto.UUID.cast(id),
|
{:ok, id} <- Ecto.UUID.cast(id),
|
||||||
%SupportRequest{} = request <-
|
%SupportRequest{} = request <-
|
||||||
Repo.one(
|
Repo.one(
|
||||||
|
|
@ -487,7 +494,7 @@ defmodule WhoNeedHelp.Support do
|
||||||
defp preload_conversation(%SupportRequest{} = request) do
|
defp preload_conversation(%SupportRequest{} = request) do
|
||||||
Repo.preload(
|
Repo.preload(
|
||||||
request,
|
request,
|
||||||
[conversation_messages: :sender, status_events: :actor],
|
[:assigned_to, conversation_messages: :sender, status_events: :actor],
|
||||||
force: true
|
force: true
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -565,8 +572,54 @@ defmodule WhoNeedHelp.Support do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_kind(query, nil), do: query
|
defp maybe_kind(query, nil), do: query
|
||||||
|
defp maybe_kind(query, ""), do: query
|
||||||
defp maybe_kind(query, kind), do: where(query, [request], request.kind == ^kind)
|
defp maybe_kind(query, kind), do: where(query, [request], request.kind == ^kind)
|
||||||
|
|
||||||
|
defp maybe_status(query, nil), do: query
|
||||||
|
defp maybe_status(query, ""), do: query
|
||||||
|
defp maybe_status(query, status), do: where(query, [request], request.status == ^status)
|
||||||
|
|
||||||
|
defp maybe_assignee(query, nil, _current_user_id), do: query
|
||||||
|
defp maybe_assignee(query, "", _current_user_id), do: query
|
||||||
|
|
||||||
|
defp maybe_assignee(query, "unassigned", _current_user_id),
|
||||||
|
do: where(query, [r], is_nil(r.assigned_to_id))
|
||||||
|
|
||||||
|
defp maybe_assignee(query, "mine", current_user_id),
|
||||||
|
do: where(query, [r], r.assigned_to_id == ^current_user_id)
|
||||||
|
|
||||||
|
defp maybe_assignee(query, assignee_id, _current_user_id),
|
||||||
|
do: where(query, [r], r.assigned_to_id == ^assignee_id)
|
||||||
|
|
||||||
|
defp maybe_search(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_search(query, value) do
|
||||||
|
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
||||||
|
pattern = "%#{term}%"
|
||||||
|
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[request],
|
||||||
|
ilike(request.reference, ^pattern) or ilike(request.contact_email, ^pattern) or
|
||||||
|
ilike(request.subject, ^pattern)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_assignment(attrs, permission) do
|
||||||
|
case Map.fetch(attrs, "assigned_to_id") do
|
||||||
|
:error ->
|
||||||
|
{:ok, attrs}
|
||||||
|
|
||||||
|
{:ok, value} when value in [nil, ""] ->
|
||||||
|
{:ok, Map.put(attrs, "assigned_to_id", nil)}
|
||||||
|
|
||||||
|
{:ok, user_id} ->
|
||||||
|
if Accounts.authorized_user_id?(user_id, permission),
|
||||||
|
do: {:ok, attrs},
|
||||||
|
else: {:error, :invalid_assignee}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp rate_scopes(user, contact_email, client_scope) do
|
defp rate_scopes(user, contact_email, client_scope) do
|
||||||
[{:support_request, rate_scope(user, contact_email)}]
|
[{:support_request, rate_scope(user, contact_email)}]
|
||||||
|> maybe_add_client_rate_scope(client_scope)
|
|> maybe_add_client_rate_scope(client_scope)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ defmodule WhoNeedHelp.Support.SupportRequest do
|
||||||
field :response_sent_at, :utc_datetime
|
field :response_sent_at, :utc_datetime
|
||||||
belongs_to :requester, WhoNeedHelp.Accounts.User
|
belongs_to :requester, WhoNeedHelp.Accounts.User
|
||||||
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
|
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
|
||||||
|
belongs_to :assigned_to, WhoNeedHelp.Accounts.User
|
||||||
|
|
||||||
has_many :conversation_messages, WhoNeedHelp.Support.ConversationMessage,
|
has_many :conversation_messages, WhoNeedHelp.Support.ConversationMessage,
|
||||||
preload_order: [asc: :inserted_at, asc: :id]
|
preload_order: [asc: :inserted_at, asc: :id]
|
||||||
|
|
@ -68,7 +69,14 @@ defmodule WhoNeedHelp.Support.SupportRequest do
|
||||||
|
|
||||||
def moderation_changeset(request, attrs) do
|
def moderation_changeset(request, attrs) do
|
||||||
request
|
request
|
||||||
|> cast(attrs, [:status, :resolution_note, :reviewed_at, :reviewed_by_id, :response_sent_at])
|
|> cast(attrs, [
|
||||||
|
:status,
|
||||||
|
:resolution_note,
|
||||||
|
:reviewed_at,
|
||||||
|
:reviewed_by_id,
|
||||||
|
:response_sent_at,
|
||||||
|
:assigned_to_id
|
||||||
|
])
|
||||||
|> validate_required([:status, :reviewed_at, :reviewed_by_id])
|
|> validate_required([:status, :reviewed_at, :reviewed_by_id])
|
||||||
|> validate_length(:resolution_note, max: 5_000)
|
|> validate_length(:resolution_note, max: 5_000)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
end
|
end
|
||||||
|
|
||||||
def paginate_reports(%Scope{user: user}, status \\ nil, options \\ []) do
|
def paginate_reports(%Scope{user: user}, status \\ nil, options \\ []) do
|
||||||
if Accounts.moderator_authorized?(user) do
|
if Accounts.authorized?(user, :moderation_view) do
|
||||||
limit = Pagination.limit(options)
|
limit = Pagination.limit(options)
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
|
|
@ -359,7 +359,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
|
|
||||||
def moderate_report(%Scope{user: moderator}, report_id, attrs) do
|
def moderate_report(%Scope{user: moderator}, report_id, attrs) do
|
||||||
with {:ok, report_id} <- cast_id(report_id),
|
with {:ok, report_id} <- cast_id(report_id),
|
||||||
true <- Accounts.moderator_authorized?(moderator) do
|
true <- Accounts.authorized?(moderator, :moderation_manage) do
|
||||||
attrs =
|
attrs =
|
||||||
attrs
|
attrs
|
||||||
|> stringify_keys()
|
|> stringify_keys()
|
||||||
|
|
@ -413,7 +413,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
|
|
||||||
def report_evidence(%Scope{user: moderator}, report_id) do
|
def report_evidence(%Scope{user: moderator}, report_id) do
|
||||||
with {:ok, report_id} <- cast_id(report_id),
|
with {:ok, report_id} <- cast_id(report_id),
|
||||||
true <- Accounts.moderator_authorized?(moderator) do
|
true <- Accounts.authorized?(moderator, :moderation_evidence_view) do
|
||||||
report =
|
report =
|
||||||
Report
|
Report
|
||||||
|> Repo.get(report_id)
|
|> Repo.get(report_id)
|
||||||
|
|
@ -667,7 +667,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
end
|
end
|
||||||
|
|
||||||
def paginate_abuse_signals(%Scope{user: user}, status \\ :open, options \\ []) do
|
def paginate_abuse_signals(%Scope{user: user}, status \\ :open, options \\ []) do
|
||||||
if Accounts.moderator_authorized?(user) do
|
if Accounts.authorized?(user, :moderation_view) do
|
||||||
limit = Pagination.limit(options)
|
limit = Pagination.limit(options)
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
|
|
@ -686,7 +686,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
|
|
||||||
def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do
|
def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do
|
||||||
with {:ok, signal_id} <- cast_id(signal_id),
|
with {:ok, signal_id} <- cast_id(signal_id),
|
||||||
true <- Accounts.moderator_authorized?(moderator) do
|
true <- Accounts.authorized?(moderator, :moderation_manage) do
|
||||||
attrs =
|
attrs =
|
||||||
attrs
|
attrs
|
||||||
|> stringify_keys()
|
|> stringify_keys()
|
||||||
|
|
@ -723,7 +723,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
def hide_request(%Scope{user: moderator}, request_id, reason) do
|
def hide_request(%Scope{user: moderator}, request_id, reason) do
|
||||||
result =
|
result =
|
||||||
with {:ok, request_id} <- cast_id(request_id),
|
with {:ok, request_id} <- cast_id(request_id),
|
||||||
true <- Accounts.moderator_authorized?(moderator) do
|
true <- Accounts.authorized?(moderator, :moderation_manage) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
request =
|
request =
|
||||||
HelpRequest
|
HelpRequest
|
||||||
|
|
@ -763,7 +763,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
def restore_request(%Scope{user: moderator}, request_id) do
|
def restore_request(%Scope{user: moderator}, request_id) do
|
||||||
result =
|
result =
|
||||||
with {:ok, request_id} <- cast_id(request_id),
|
with {:ok, request_id} <- cast_id(request_id),
|
||||||
true <- Accounts.moderator_authorized?(moderator) do
|
true <- Accounts.authorized?(moderator, :moderation_manage) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
request =
|
request =
|
||||||
HelpRequest
|
HelpRequest
|
||||||
|
|
@ -798,7 +798,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
def hide_activity(%Scope{user: moderator}, activity_id, reason) do
|
def hide_activity(%Scope{user: moderator}, activity_id, reason) do
|
||||||
result =
|
result =
|
||||||
with {:ok, activity_id} <- cast_id(activity_id),
|
with {:ok, activity_id} <- cast_id(activity_id),
|
||||||
true <- Accounts.moderator_authorized?(moderator) do
|
true <- Accounts.authorized?(moderator, :moderation_manage) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
activity =
|
activity =
|
||||||
Activity
|
Activity
|
||||||
|
|
@ -838,7 +838,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
def restore_activity(%Scope{user: moderator}, activity_id) do
|
def restore_activity(%Scope{user: moderator}, activity_id) do
|
||||||
result =
|
result =
|
||||||
with {:ok, activity_id} <- cast_id(activity_id),
|
with {:ok, activity_id} <- cast_id(activity_id),
|
||||||
true <- Accounts.moderator_authorized?(moderator) do
|
true <- Accounts.authorized?(moderator, :moderation_manage) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
activity =
|
activity =
|
||||||
Activity
|
Activity
|
||||||
|
|
@ -910,16 +910,42 @@ defmodule WhoNeedHelp.Trust do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def moderate_role(%Scope{user: admin}, user_id, attrs) do
|
def set_staff_roles(%Scope{user: admin}, user_id, roles) do
|
||||||
Repo.transact(fn ->
|
result =
|
||||||
with {:ok, user} <- Accounts.change_user_role(admin, user_id, attrs),
|
Repo.transact(fn ->
|
||||||
{:ok, _audit} <-
|
with {:ok, %{user: user} = change} <- Accounts.set_staff_roles(admin, user_id, roles),
|
||||||
audit(admin.id, "user.role_changed", "user", user.id, %{
|
{:ok, _audit} <-
|
||||||
"role" => to_string(user.role)
|
audit(admin.id, "user.staff_roles_changed", "user", user.id, %{
|
||||||
}) do
|
"roles" => Enum.map(Accounts.loaded_staff_roles(user), &to_string/1)
|
||||||
{:ok, user}
|
}) do
|
||||||
end
|
{:ok, change}
|
||||||
end)
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
with {:ok, %{user: user, session_tokens: session_tokens}} <- result do
|
||||||
|
WhoNeedHelpWeb.UserAuth.disconnect_sessions(session_tokens)
|
||||||
|
{:ok, user}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_audit_events(%Scope{user: user}, options \\ []) do
|
||||||
|
if Accounts.authorized?(user, :audit_view) do
|
||||||
|
limit = Pagination.limit(options)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
|
AuditEvent
|
||||||
|
|> maybe_audit_action(Keyword.get(options, :action))
|
||||||
|
|> maybe_audit_actor(Keyword.get(options, :actor_id))
|
||||||
|
|> maybe_audit_search(Keyword.get(options, :search))
|
||||||
|
|> before_audit_event(cursor)
|
||||||
|
|> order_by([event], desc: event.inserted_at, desc: event.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|
|> preload(:actor)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
|
else
|
||||||
|
%Pagination.Page{}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do
|
def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do
|
||||||
|
|
@ -934,6 +960,43 @@ defmodule WhoNeedHelp.Trust do
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_audit_action(query, value) when value in [nil, ""], do: query
|
||||||
|
defp maybe_audit_action(query, value), do: where(query, [event], event.action == ^value)
|
||||||
|
|
||||||
|
defp maybe_audit_actor(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_audit_actor(query, value) do
|
||||||
|
case cast_id(value) do
|
||||||
|
{:ok, actor_id} -> where(query, [event], event.actor_id == ^actor_id)
|
||||||
|
_ -> where(query, [event], false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_audit_search(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_audit_search(query, value) do
|
||||||
|
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
||||||
|
pattern = "%#{term}%"
|
||||||
|
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[event],
|
||||||
|
ilike(event.action, ^pattern) or ilike(event.target_type, ^pattern) or
|
||||||
|
fragment("CAST(? AS text) ILIKE ?", event.target_id, ^pattern)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp before_audit_event(query, nil), do: query
|
||||||
|
|
||||||
|
defp before_audit_event(query, {inserted_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[event],
|
||||||
|
event.inserted_at < ^inserted_at or
|
||||||
|
(event.inserted_at == ^inserted_at and event.id < ^id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp users_by_id([]), do: %{}
|
defp users_by_id([]), do: %{}
|
||||||
|
|
||||||
defp users_by_id(ids) do
|
defp users_by_id(ids) do
|
||||||
|
|
|
||||||
139
lib/who_need_help_web/components/admin_components.ex
Normal file
139
lib/who_need_help_web/components/admin_components.ex
Normal 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
|
||||||
|
|
@ -142,16 +142,13 @@ defmodule WhoNeedHelpWeb.Layouts do
|
||||||
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
|
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
:if={@current_scope.user.role in [:moderator, :admin]}
|
:if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}
|
||||||
class="menu-title mt-1 border-t border-base-300 pt-2"
|
class="menu-title mt-1 border-t border-base-300 pt-2"
|
||||||
>
|
>
|
||||||
{gettext("Operations")}
|
{gettext("Operations")}
|
||||||
</li>
|
</li>
|
||||||
<li :if={@current_scope.user.role in [:moderator, :admin]}>
|
<li :if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}>
|
||||||
<.link navigate={~p"/moderation"}>{gettext("Moderation")}</.link>
|
<.link navigate={~p"/admin"}>{gettext("Staff workspace")}</.link>
|
||||||
</li>
|
|
||||||
<li :if={@current_scope.user.role in [:moderator, :admin]}>
|
|
||||||
<.link navigate={~p"/support/operations"}>{gettext("Support queue")}</.link>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>
|
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>
|
||||||
|
|
@ -387,16 +384,13 @@ defmodule WhoNeedHelpWeb.Layouts do
|
||||||
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
|
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
:if={@current_scope.user.role in [:moderator, :admin]}
|
:if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}
|
||||||
class="menu-title mt-1 border-t border-base-300 pt-2"
|
class="menu-title mt-1 border-t border-base-300 pt-2"
|
||||||
>
|
>
|
||||||
{gettext("Operations")}
|
{gettext("Operations")}
|
||||||
</li>
|
</li>
|
||||||
<li :if={@current_scope.user.role in [:moderator, :admin]}>
|
<li :if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}>
|
||||||
<.link navigate={~p"/moderation"}>{gettext("Moderation")}</.link>
|
<.link navigate={~p"/admin"}>{gettext("Staff workspace")}</.link>
|
||||||
</li>
|
|
||||||
<li :if={@current_scope.user.role in [:moderator, :admin]}>
|
|
||||||
<.link navigate={~p"/support/operations"}>{gettext("Support queue")}</.link>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>
|
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>
|
||||||
|
|
|
||||||
186
lib/who_need_help_web/live/admin_audit_live.ex
Normal file
186
lib/who_need_help_web/live/admin_audit_live.ex
Normal 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
|
||||||
136
lib/who_need_help_web/live/admin_dashboard_live.ex
Normal file
136
lib/who_need_help_web/live/admin_dashboard_live.ex
Normal 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
|
||||||
293
lib/who_need_help_web/live/admin_users_live.ex
Normal file
293
lib/who_need_help_web/live/admin_users_live.ex
Normal 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
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
defmodule WhoNeedHelpWeb.ModerationLive do
|
defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
use WhoNeedHelpWeb, :live_view
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
alias WhoNeedHelp.{Accounts, Catalog, Trust}
|
alias WhoNeedHelp.{Catalog, Trust}
|
||||||
|
alias WhoNeedHelpWeb.AdminComponents
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
@ -35,22 +36,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("moderate-user", %{"id" => id, "moderation" => params}, socket) do
|
|
||||||
respond(
|
|
||||||
socket,
|
|
||||||
Trust.moderate_user(socket.assigns.current_scope, id, params),
|
|
||||||
gettext("User status updated.")
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("moderate-role", %{"id" => id, "moderation" => params}, socket) do
|
|
||||||
respond(
|
|
||||||
socket,
|
|
||||||
Trust.moderate_role(socket.assigns.current_scope, id, params),
|
|
||||||
gettext("User role updated.")
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("hide-request", %{"id" => id, "moderation" => %{"note" => note}}, socket) do
|
def handle_event("hide-request", %{"id" => id, "moderation" => %{"note" => note}}, socket) do
|
||||||
respond(
|
respond(
|
||||||
socket,
|
socket,
|
||||||
|
|
@ -173,15 +158,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
{:noreply, append_page(socket, :proposals, page)}
|
{:noreply, append_page(socket, :proposals, page)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("load-more-users", _params, socket) do
|
|
||||||
page =
|
|
||||||
Accounts.paginate_users_for_moderation(socket.assigns.current_scope,
|
|
||||||
after: socket.assigns.users_cursor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:noreply, append_page(socket, :users, page)}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp respond(socket, {:ok, _value}, message) do
|
defp respond(socket, {:ok, _value}, message) do
|
||||||
{:noreply, socket |> put_flash(:info, message) |> load()}
|
{:noreply, socket |> put_flash(:info, message) |> load()}
|
||||||
end
|
end
|
||||||
|
|
@ -198,7 +174,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
reports = Trust.paginate_reports(socket.assigns.current_scope)
|
reports = Trust.paginate_reports(socket.assigns.current_scope)
|
||||||
signals = Trust.paginate_abuse_signals(socket.assigns.current_scope)
|
signals = Trust.paginate_abuse_signals(socket.assigns.current_scope)
|
||||||
proposals = Catalog.paginate_proposals_for_moderation(socket.assigns.current_scope)
|
proposals = Catalog.paginate_proposals_for_moderation(socket.assigns.current_scope)
|
||||||
users = Accounts.paginate_users_for_moderation(socket.assigns.current_scope)
|
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:reports, reports.entries)
|
|> assign(:reports, reports.entries)
|
||||||
|
|
@ -207,8 +182,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
|> assign(:signals_cursor, signals.next_cursor)
|
|> assign(:signals_cursor, signals.next_cursor)
|
||||||
|> assign(:proposals, proposals.entries)
|
|> assign(:proposals, proposals.entries)
|
||||||
|> assign(:proposals_cursor, proposals.next_cursor)
|
|> assign(:proposals_cursor, proposals.next_cursor)
|
||||||
|> assign(:users, users.entries)
|
|
||||||
|> assign(:users_cursor, users.next_cursor)
|
|
||||||
|> assign(:categories, Catalog.list_all_categories())
|
|> assign(:categories, Catalog.list_all_categories())
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -225,7 +198,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
defp cursor_assign(:reports), do: :reports_cursor
|
defp cursor_assign(:reports), do: :reports_cursor
|
||||||
defp cursor_assign(:signals), do: :signals_cursor
|
defp cursor_assign(:signals), do: :signals_cursor
|
||||||
defp cursor_assign(:proposals), do: :proposals_cursor
|
defp cursor_assign(:proposals), do: :proposals_cursor
|
||||||
defp cursor_assign(:users), do: :users_cursor
|
|
||||||
|
|
||||||
defp error_message(:forbidden), do: gettext("Moderator access is required.")
|
defp error_message(:forbidden), do: gettext("Moderator access is required.")
|
||||||
defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.")
|
defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.")
|
||||||
|
|
@ -268,11 +240,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
defp status_label(:suspended), do: gettext("Suspended")
|
defp status_label(:suspended), do: gettext("Suspended")
|
||||||
defp status_label(value), do: to_string(value)
|
defp status_label(value), do: to_string(value)
|
||||||
|
|
||||||
defp role_label(:user), do: gettext("User")
|
|
||||||
defp role_label(:moderator), do: gettext("Moderator")
|
|
||||||
defp role_label(:admin), do: gettext("Administrator")
|
|
||||||
defp role_label(value), do: to_string(value)
|
|
||||||
|
|
||||||
defp signal_label(:velocity), do: gettext("Unusual action rate")
|
defp signal_label(:velocity), do: gettext("Unusual action rate")
|
||||||
defp signal_label(:repeated_pair), do: gettext("Repeated participant pair")
|
defp signal_label(:repeated_pair), do: gettext("Repeated participant pair")
|
||||||
|
|
||||||
|
|
@ -316,30 +283,20 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
|
|
||||||
defp report_activity(_report), do: nil
|
defp report_activity(_report), do: nil
|
||||||
|
|
||||||
defp can_moderate_account?(%{role: :admin}, _target), do: true
|
|
||||||
defp can_moderate_account?(%{role: :moderator}, %{role: :user}), do: true
|
|
||||||
defp can_moderate_account?(_actor, _target), do: false
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<AdminComponents.shell
|
||||||
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div>
|
flash={@flash}
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
current_scope={@current_scope}
|
||||||
<h1 class="mt-1 text-4xl font-black">{gettext("Moderation")}</h1>
|
active={:moderation}
|
||||||
<div class="flex flex-wrap gap-2">
|
title={gettext("Trust and safety")}
|
||||||
<.link navigate={~p"/analytics"} class="btn btn-outline btn-sm">
|
description={
|
||||||
{gettext("Product analytics")}
|
gettext(
|
||||||
</.link>
|
"Review reports and automated abuse signals, inspect only scoped evidence, and moderate category proposals. Every sensitive action is audited."
|
||||||
<.link navigate={~p"/support/operations"} class="btn btn-outline btn-sm">
|
)
|
||||||
{gettext("Support operations")}
|
}
|
||||||
</.link>
|
>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-base-content/60">
|
|
||||||
{gettext("Decisions and access to reported chat evidence are written to the audit log.")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
<h2 class="text-2xl font-bold">{gettext("Reports")}</h2>
|
<h2 class="text-2xl font-bold">{gettext("Reports")}</h2>
|
||||||
<div class="mt-4 space-y-4">
|
<div class="mt-4 space-y-4">
|
||||||
|
|
@ -651,106 +608,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
{gettext("Load more")}
|
{gettext("Load more")}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
</AdminComponents.shell>
|
||||||
<section class="mt-10">
|
|
||||||
<h2 class="text-2xl font-bold">{gettext("Accounts")}</h2>
|
|
||||||
<div class="mt-4 overflow-x-auto rounded-2xl border border-base-300">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{gettext("User")}</th>
|
|
||||||
<th>{gettext("Role")}</th>
|
|
||||||
<th>{gettext("Status")}</th>
|
|
||||||
<th>{gettext("Decision")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr :for={user <- @users}>
|
|
||||||
<% user_form =
|
|
||||||
scoped_form(
|
|
||||||
%{
|
|
||||||
"moderation_status" => to_string(user.moderation_status),
|
|
||||||
"moderation_note" => user.moderation_note || ""
|
|
||||||
},
|
|
||||||
:moderation,
|
|
||||||
"user-status-#{user.id}"
|
|
||||||
) %>
|
|
||||||
<% role_form =
|
|
||||||
scoped_form(
|
|
||||||
%{"role" => to_string(user.role)},
|
|
||||||
:moderation,
|
|
||||||
"user-role-#{user.id}"
|
|
||||||
) %>
|
|
||||||
<td>
|
|
||||||
{user.display_name}
|
|
||||||
<div class="text-xs opacity-50">{user.email}</div>
|
|
||||||
</td>
|
|
||||||
<td>{role_label(user.role)}</td>
|
|
||||||
<td>{status_label(user.moderation_status)}</td>
|
|
||||||
<td>
|
|
||||||
<.form
|
|
||||||
:if={can_moderate_account?(@current_scope.user, user)}
|
|
||||||
for={user_form}
|
|
||||||
phx-submit="moderate-user"
|
|
||||||
phx-value-id={user.id}
|
|
||||||
class="flex min-w-[28rem] gap-2"
|
|
||||||
>
|
|
||||||
<.input
|
|
||||||
field={user_form[:moderation_status]}
|
|
||||||
type="select"
|
|
||||||
options={[
|
|
||||||
{gettext("Active"), "active"},
|
|
||||||
{gettext("Restricted"), "restricted"},
|
|
||||||
{gettext("Suspended"), "suspended"}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<.input
|
|
||||||
field={user_form[:moderation_note]}
|
|
||||||
placeholder={gettext("Internal note")}
|
|
||||||
/>
|
|
||||||
<.button class="btn btn-sm btn-primary self-end">{gettext("Save")}</.button>
|
|
||||||
</.form>
|
|
||||||
<p
|
|
||||||
:if={not can_moderate_account?(@current_scope.user, user)}
|
|
||||||
class="text-xs text-base-content/55"
|
|
||||||
>
|
|
||||||
{gettext("Administrator access is required to moderate staff accounts.")}
|
|
||||||
</p>
|
|
||||||
<.form
|
|
||||||
:if={@current_scope.user.role == :admin}
|
|
||||||
for={role_form}
|
|
||||||
phx-submit="moderate-role"
|
|
||||||
phx-value-id={user.id}
|
|
||||||
class="mt-2 flex gap-2"
|
|
||||||
>
|
|
||||||
<.input
|
|
||||||
field={role_form[:role]}
|
|
||||||
type="select"
|
|
||||||
options={[
|
|
||||||
{gettext("User"), "user"},
|
|
||||||
{gettext("Moderator"), "moderator"},
|
|
||||||
{gettext("Administrator"), "admin"}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<.button class="btn btn-sm btn-warning self-end">
|
|
||||||
{gettext("Change role")}
|
|
||||||
</.button>
|
|
||||||
</.form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
:if={@users_cursor}
|
|
||||||
type="button"
|
|
||||||
phx-click="load-more-users"
|
|
||||||
class="btn btn-outline btn-sm mt-4"
|
|
||||||
>
|
|
||||||
{gettext("Load more")}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
</Layouts.app>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
|
||||||
use WhoNeedHelpWeb, :live_view
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
alias WhoNeedHelp.ProductAnalytics
|
alias WhoNeedHelp.ProductAnalytics
|
||||||
|
alias WhoNeedHelpWeb.AdminComponents
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
@ -35,23 +36,18 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}>
|
<AdminComponents.shell
|
||||||
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div>
|
flash={@flash}
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
current_scope={@current_scope}
|
||||||
<div>
|
active={:analytics}
|
||||||
<h1 class="mt-1 text-4xl font-black">{gettext("Product analytics")}</h1>
|
title={gettext("Product analytics")}
|
||||||
<p class="mt-2 text-base-content/60">
|
description={
|
||||||
{gettext(
|
gettext(
|
||||||
"Daily aggregate counters only. No user ID, email, coordinate, request text, chat text, medicine name, or device credential is stored here."
|
"Daily aggregate counters only. No user ID, email, coordinate, request text, chat text, medicine name, or device credential is stored here."
|
||||||
)}
|
)
|
||||||
</p>
|
}
|
||||||
</div>
|
>
|
||||||
<.link navigate={~p"/moderation"} class="btn btn-outline btn-sm">
|
<div class="overflow-x-auto rounded-3xl border border-base-300 bg-base-100">
|
||||||
{gettext("Back to moderation")}
|
|
||||||
</.link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 overflow-x-auto rounded-3xl border border-base-300">
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -81,11 +77,11 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
|
||||||
:if={@metrics_cursor}
|
:if={@metrics_cursor}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="load-more"
|
phx-click="load-more"
|
||||||
class="btn btn-outline btn-sm mt-4"
|
class="btn btn-outline btn-sm"
|
||||||
>
|
>
|
||||||
{gettext("Load more")}
|
{gettext("Load more")}
|
||||||
</button>
|
</button>
|
||||||
</Layouts.app>
|
</AdminComponents.shell>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,33 @@
|
||||||
defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
use WhoNeedHelpWeb, :live_view
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
alias WhoNeedHelp.{ContentRemoval, Support}
|
alias WhoNeedHelp.{Accounts, ContentRemoval, Support}
|
||||||
|
alias WhoNeedHelpWeb.AdminComponents
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
if connected?(socket), do: Support.subscribe_staff()
|
support_allowed = Accounts.permission?(socket.assigns.current_scope.user, :support_view)
|
||||||
|
legal_allowed = Accounts.permission?(socket.assigns.current_scope.user, :legal_view)
|
||||||
|
|
||||||
|
if connected?(socket) and support_allowed, do: Support.subscribe_staff()
|
||||||
|
|
||||||
|
support_filters = %{"search" => "", "status" => "", "kind" => "", "assigned_to_id" => ""}
|
||||||
|
legal_filters = %{"search" => "", "status" => "", "regime" => "", "assigned_to_id" => ""}
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Support operations"))
|
|> assign(:page_title, gettext("Support operations"))
|
||||||
|
|> assign(:operations_title, operations_title(support_allowed, legal_allowed))
|
||||||
|
|> assign(:operations_description, operations_description(support_allowed, legal_allowed))
|
||||||
|> assign(:deletion_assessments, %{})
|
|> assign(:deletion_assessments, %{})
|
||||||
|
|> assign(:support_allowed, support_allowed)
|
||||||
|
|> assign(:legal_allowed, legal_allowed)
|
||||||
|
|> assign(:support_filters, support_filters)
|
||||||
|
|> assign(:legal_filters, legal_filters)
|
||||||
|
|> assign(:support_filter_form, to_form(support_filters, as: :support_filters))
|
||||||
|
|> assign(:legal_filter_form, to_form(legal_filters, as: :legal_filters))
|
||||||
|
|> assign(:support_assignees, staff_assignees(support_allowed, :support_manage))
|
||||||
|
|> assign(:legal_assignees, staff_assignees(legal_allowed, :legal_manage))
|
||||||
|> load()}
|
|> load()}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -36,6 +53,26 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("filter-support", %{"support_filters" => filters}, socket) do
|
||||||
|
filters = Map.merge(socket.assigns.support_filters, filters)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:support_filters, filters)
|
||||||
|
|> assign(:support_filter_form, to_form(filters, as: :support_filters))
|
||||||
|
|> load_support()}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("filter-legal", %{"legal_filters" => filters}, socket) do
|
||||||
|
filters = Map.merge(socket.assigns.legal_filters, filters)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:legal_filters, filters)
|
||||||
|
|> assign(:legal_filter_form, to_form(filters, as: :legal_filters))
|
||||||
|
|> load_removals()}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("assess-deletion", %{"id" => id}, socket) do
|
def handle_event("assess-deletion", %{"id" => id}, socket) do
|
||||||
case Support.deletion_assessment(socket.assigns.current_scope, id) do
|
case Support.deletion_assessment(socket.assigns.current_scope, id) do
|
||||||
{:ok, assessment} ->
|
{:ok, assessment} ->
|
||||||
|
|
@ -48,8 +85,9 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
|
|
||||||
def handle_event("load-more-support", _params, socket) do
|
def handle_event("load-more-support", _params, socket) do
|
||||||
page =
|
page =
|
||||||
Support.paginate_for_staff(socket.assigns.current_scope,
|
Support.paginate_for_staff(
|
||||||
after: socket.assigns.support_cursor
|
socket.assigns.current_scope,
|
||||||
|
support_options(socket, socket.assigns.support_cursor)
|
||||||
)
|
)
|
||||||
|
|
||||||
{:noreply, append_page(socket, :support_requests, :support_cursor, page)}
|
{:noreply, append_page(socket, :support_requests, :support_cursor, page)}
|
||||||
|
|
@ -57,8 +95,9 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
|
|
||||||
def handle_event("load-more-removals", _params, socket) do
|
def handle_event("load-more-removals", _params, socket) do
|
||||||
page =
|
page =
|
||||||
ContentRemoval.paginate_for_staff(socket.assigns.current_scope,
|
ContentRemoval.paginate_for_staff(
|
||||||
after: socket.assigns.removal_cursor
|
socket.assigns.current_scope,
|
||||||
|
legal_options(socket, socket.assigns.removal_cursor)
|
||||||
)
|
)
|
||||||
|
|
||||||
{:noreply, append_page(socket, :removal_notices, :removal_cursor, page)}
|
{:noreply, append_page(socket, :removal_notices, :removal_cursor, page)}
|
||||||
|
|
@ -73,14 +112,69 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load(socket) do
|
defp load(socket) do
|
||||||
support = Support.paginate_for_staff(socket.assigns.current_scope)
|
|
||||||
removals = ContentRemoval.paginate_for_staff(socket.assigns.current_scope)
|
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:support_requests, support.entries)
|
|> load_support()
|
||||||
|> assign(:support_cursor, support.next_cursor)
|
|> load_removals()
|
||||||
|> assign(:removal_notices, removals.entries)
|
end
|
||||||
|> assign(:removal_cursor, removals.next_cursor)
|
|
||||||
|
defp load_support(%{assigns: %{support_allowed: false}} = socket) do
|
||||||
|
socket |> assign(:support_requests, []) |> assign(:support_cursor, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_support(socket) do
|
||||||
|
page = Support.paginate_for_staff(socket.assigns.current_scope, support_options(socket))
|
||||||
|
socket |> assign(:support_requests, page.entries) |> assign(:support_cursor, page.next_cursor)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_removals(%{assigns: %{legal_allowed: false}} = socket) do
|
||||||
|
socket |> assign(:removal_notices, []) |> assign(:removal_cursor, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_removals(socket) do
|
||||||
|
page = ContentRemoval.paginate_for_staff(socket.assigns.current_scope, legal_options(socket))
|
||||||
|
socket |> assign(:removal_notices, page.entries) |> assign(:removal_cursor, page.next_cursor)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp support_options(socket, cursor \\ nil) do
|
||||||
|
filters = socket.assigns.support_filters
|
||||||
|
|
||||||
|
[
|
||||||
|
search: filters["search"],
|
||||||
|
status: filters["status"],
|
||||||
|
kind: filters["kind"],
|
||||||
|
assigned_to_id: filters["assigned_to_id"],
|
||||||
|
after: cursor
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp legal_options(socket, cursor \\ nil) do
|
||||||
|
filters = socket.assigns.legal_filters
|
||||||
|
|
||||||
|
[
|
||||||
|
search: filters["search"],
|
||||||
|
status: filters["status"],
|
||||||
|
regime: filters["regime"],
|
||||||
|
assigned_to_id: filters["assigned_to_id"],
|
||||||
|
after: cursor
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp staff_assignees(false, _permission), do: []
|
||||||
|
defp staff_assignees(true, permission), do: Accounts.list_staff_for_permission(permission)
|
||||||
|
|
||||||
|
defp assignee_options(users) do
|
||||||
|
[
|
||||||
|
{gettext("Any assignee"), ""},
|
||||||
|
{gettext("Unassigned"), "unassigned"},
|
||||||
|
{gettext("Assigned to me"), "mine"}
|
||||||
|
| Enum.map(users, fn user ->
|
||||||
|
{user.display_name || user.email, user.id}
|
||||||
|
end)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assignment_options(users) do
|
||||||
|
[{gettext("Unassigned"), ""} | Enum.map(users, &{&1.display_name || &1.email, &1.id})]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp append_page(socket, entries_key, cursor_key, page) do
|
defp append_page(socket, entries_key, cursor_key, page) do
|
||||||
|
|
@ -97,6 +191,10 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
defp error_message(:account_not_linked), do: gettext("The request is not linked to an account.")
|
defp error_message(:account_not_linked), do: gettext("The request is not linked to an account.")
|
||||||
defp error_message(:contact_not_verified), do: gettext("Verify the requester contact first.")
|
defp error_message(:contact_not_verified), do: gettext("Verify the requester contact first.")
|
||||||
defp error_message(:account_not_found), do: gettext("The linked account no longer exists.")
|
defp error_message(:account_not_found), do: gettext("The linked account no longer exists.")
|
||||||
|
|
||||||
|
defp error_message(:invalid_assignee),
|
||||||
|
do: gettext("The selected assignee cannot manage this queue.")
|
||||||
|
|
||||||
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
|
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
|
||||||
defp error_message(_reason), do: gettext("Could not update the request. Please try again.")
|
defp error_message(_reason), do: gettext("Could not update the request. Please try again.")
|
||||||
|
|
||||||
|
|
@ -129,27 +227,86 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
|
|
||||||
defp scoped_form(data, id), do: to_form(data, as: :moderation, id: id)
|
defp scoped_form(data, id), do: to_form(data, as: :moderation, id: id)
|
||||||
|
|
||||||
|
defp operations_title(true, true), do: gettext("Support and legal operations")
|
||||||
|
defp operations_title(true, false), do: gettext("Support operations")
|
||||||
|
defp operations_title(false, true), do: gettext("Legal and content removal operations")
|
||||||
|
|
||||||
|
defp operations_description(true, true) do
|
||||||
|
gettext(
|
||||||
|
"Verified support conversations and legal removal notices stay in separate permission-scoped queues. Assignments, decisions, and operator identity are audited."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp operations_description(true, false) do
|
||||||
|
gettext(
|
||||||
|
"Verified support conversations stay in a permission-scoped queue. Assignments, replies, decisions, and operator identity are audited."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp operations_description(false, true) do
|
||||||
|
gettext(
|
||||||
|
"Verified legal and content-removal notices stay in a permission-scoped queue. Assignments, decisions, and operator identity are audited."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
<AdminComponents.shell
|
||||||
<div class="text-sm font-semibold text-warning">{gettext("RESTRICTED WORKSPACE")}</div>
|
flash={@flash}
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
current_scope={@current_scope}
|
||||||
<div>
|
active={:support}
|
||||||
<h1 class="mt-1 text-4xl font-black">{gettext("Support operations")}</h1>
|
title={@operations_title}
|
||||||
<p class="mt-2 text-base-content/60">
|
description={@operations_description}
|
||||||
{gettext(
|
>
|
||||||
"Support requests and legal removal notices are separate queues. Decisions and operator identity are audited."
|
<section :if={@legal_allowed}>
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<.link navigate={~p"/moderation"} class="btn btn-outline btn-sm">
|
|
||||||
{gettext("Trust and safety moderation")}
|
|
||||||
</.link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="mt-8">
|
|
||||||
<h2 class="text-2xl font-bold">{gettext("Content removal and TAKE IT DOWN")}</h2>
|
<h2 class="text-2xl font-bold">{gettext("Content removal and TAKE IT DOWN")}</h2>
|
||||||
|
<.form
|
||||||
|
for={@legal_filter_form}
|
||||||
|
id="legal-case-filters"
|
||||||
|
phx-change="filter-legal"
|
||||||
|
class="mt-4 grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-4"
|
||||||
|
>
|
||||||
|
<.input
|
||||||
|
field={@legal_filter_form[:search]}
|
||||||
|
type="search"
|
||||||
|
label={gettext("Search legal cases")}
|
||||||
|
placeholder={gettext("Reference, email, URL, or explanation")}
|
||||||
|
phx-debounce="300"
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@legal_filter_form[:status]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Status")}
|
||||||
|
options={[
|
||||||
|
{gettext("Any status"), ""},
|
||||||
|
{gettext("Open"), "open"},
|
||||||
|
{gettext("Urgent review"), "urgent_review"},
|
||||||
|
{gettext("Reviewing"), "reviewing"},
|
||||||
|
{gettext("More information needed"), "needs_information"},
|
||||||
|
{gettext("Action taken"), "actioned"},
|
||||||
|
{gettext("Rejected"), "rejected"},
|
||||||
|
{gettext("Closed"), "closed"}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@legal_filter_form[:regime]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Regime")}
|
||||||
|
options={[
|
||||||
|
{gettext("Any regime"), ""},
|
||||||
|
{gettext("General"), "general"},
|
||||||
|
{gettext("DSA"), "dsa"},
|
||||||
|
{gettext("TAKE IT DOWN"), "take_it_down"}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@legal_filter_form[:assigned_to_id]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Assignee")}
|
||||||
|
options={assignee_options(@legal_assignees)}
|
||||||
|
/>
|
||||||
|
</.form>
|
||||||
<div class="mt-4 space-y-4">
|
<div class="mt-4 space-y-4">
|
||||||
<article
|
<article
|
||||||
:for={notice <- @removal_notices}
|
:for={notice <- @removal_notices}
|
||||||
|
|
@ -163,6 +320,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
scoped_form(
|
scoped_form(
|
||||||
%{
|
%{
|
||||||
"status" => to_string(notice.status),
|
"status" => to_string(notice.status),
|
||||||
|
"assigned_to_id" => notice.assigned_to_id || "",
|
||||||
"resolution_note" => notice.resolution_note || ""
|
"resolution_note" => notice.resolution_note || ""
|
||||||
},
|
},
|
||||||
"removal-#{notice.id}"
|
"removal-#{notice.id}"
|
||||||
|
|
@ -178,6 +336,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
<span :if={notice.response_due_at} class="badge badge-error badge-outline">
|
<span :if={notice.response_due_at} class="badge badge-error badge-outline">
|
||||||
{gettext("review by %{time}", time: notice.response_due_at)}
|
{gettext("review by %{time}", time: notice.response_due_at)}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="badge badge-ghost">
|
||||||
|
{if notice.assigned_to,
|
||||||
|
do:
|
||||||
|
gettext("Assigned: %{name}",
|
||||||
|
name: notice.assigned_to.display_name || notice.assigned_to.email
|
||||||
|
),
|
||||||
|
else: gettext("Unassigned")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<dl class="mt-4 grid gap-2 text-sm sm:grid-cols-[10rem_1fr]">
|
<dl class="mt-4 grid gap-2 text-sm sm:grid-cols-[10rem_1fr]">
|
||||||
<dt class="font-semibold">{gettext("Submitter")}</dt>
|
<dt class="font-semibold">{gettext("Submitter")}</dt>
|
||||||
|
|
@ -226,7 +392,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
{gettext("Audited update")}
|
{gettext("Audited update")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-5 p-4 xl:grid-cols-[15rem_minmax(0,1fr)] xl:items-start">
|
<div class="grid gap-5 p-4 xl:grid-cols-[15rem_15rem_minmax(0,1fr)] xl:items-start">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<.input
|
<.input
|
||||||
field={form[:status]}
|
field={form[:status]}
|
||||||
|
|
@ -247,6 +413,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
{gettext("Choose the state that will appear in the case history.")}
|
{gettext("Choose the state that will appear in the case history.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<.input
|
||||||
|
field={form[:assigned_to_id]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Assigned operator")}
|
||||||
|
options={assignment_options(@legal_assignees)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<.input
|
<.input
|
||||||
field={form[:resolution_note]}
|
field={form[:resolution_note]}
|
||||||
|
|
@ -307,14 +481,64 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-12">
|
<section :if={@support_allowed}>
|
||||||
<h2 class="text-2xl font-bold">{gettext("General support and data requests")}</h2>
|
<h2 class="text-2xl font-bold">{gettext("General support and data requests")}</h2>
|
||||||
|
<.form
|
||||||
|
for={@support_filter_form}
|
||||||
|
id="support-case-filters"
|
||||||
|
phx-change="filter-support"
|
||||||
|
class="mt-4 grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-4"
|
||||||
|
>
|
||||||
|
<.input
|
||||||
|
field={@support_filter_form[:search]}
|
||||||
|
type="search"
|
||||||
|
label={gettext("Search support cases")}
|
||||||
|
placeholder={gettext("Reference, email, subject, or details")}
|
||||||
|
phx-debounce="300"
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@support_filter_form[:status]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Status")}
|
||||||
|
options={[
|
||||||
|
{gettext("Any status"), ""},
|
||||||
|
{gettext("Open"), "open"},
|
||||||
|
{gettext("Reviewing"), "reviewing"},
|
||||||
|
{gettext("Waiting for requester"), "waiting_for_requester"},
|
||||||
|
{gettext("Resolved"), "resolved"},
|
||||||
|
{gettext("Closed"), "closed"}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@support_filter_form[:kind]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Request type")}
|
||||||
|
options={[
|
||||||
|
{gettext("Any type"), ""},
|
||||||
|
{gettext("Account access"), "account_access"},
|
||||||
|
{gettext("Technical issue"), "technical_issue"},
|
||||||
|
{gettext("Safety concern"), "safety_concern"},
|
||||||
|
{gettext("Moderation appeal"), "moderation_appeal"},
|
||||||
|
{gettext("Account deletion"), "account_deletion"},
|
||||||
|
{gettext("Data export"), "data_export"},
|
||||||
|
{gettext("Privacy request"), "privacy_request"},
|
||||||
|
{gettext("Other"), "other"}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@support_filter_form[:assigned_to_id]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Assignee")}
|
||||||
|
options={assignee_options(@support_assignees)}
|
||||||
|
/>
|
||||||
|
</.form>
|
||||||
<div class="mt-4 space-y-4">
|
<div class="mt-4 space-y-4">
|
||||||
<article :for={request <- @support_requests} class="rounded-2xl border border-base-300 p-5">
|
<article :for={request <- @support_requests} class="rounded-2xl border border-base-300 p-5">
|
||||||
<% form =
|
<% form =
|
||||||
scoped_form(
|
scoped_form(
|
||||||
%{
|
%{
|
||||||
"status" => to_string(request.status),
|
"status" => to_string(request.status),
|
||||||
|
"assigned_to_id" => request.assigned_to_id || "",
|
||||||
"response" => ""
|
"response" => ""
|
||||||
},
|
},
|
||||||
"support-#{request.id}"
|
"support-#{request.id}"
|
||||||
|
|
@ -326,6 +550,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
{gettext("contact unverified")}
|
{gettext("contact unverified")}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-base-content/65">{support_kind(request.kind)}</span>
|
<span class="text-xs text-base-content/65">{support_kind(request.kind)}</span>
|
||||||
|
<span class="badge badge-ghost">
|
||||||
|
{if request.assigned_to,
|
||||||
|
do:
|
||||||
|
gettext("Assigned: %{name}",
|
||||||
|
name: request.assigned_to.display_name || request.assigned_to.email
|
||||||
|
),
|
||||||
|
else: gettext("Unassigned")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="mt-3 font-bold">{request.subject}</h3>
|
<h3 class="mt-3 font-bold">{request.subject}</h3>
|
||||||
<p class="mt-1 text-xs text-base-content/65">{request.contact_email}</p>
|
<p class="mt-1 text-xs text-base-content/65">{request.contact_email}</p>
|
||||||
|
|
@ -427,7 +659,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
{gettext("Audited update")}
|
{gettext("Audited update")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-5 p-4 xl:grid-cols-[15rem_minmax(0,1fr)] xl:items-start">
|
<div class="grid gap-5 p-4 xl:grid-cols-[15rem_15rem_minmax(0,1fr)] xl:items-start">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<.input
|
<.input
|
||||||
field={form[:status]}
|
field={form[:status]}
|
||||||
|
|
@ -446,6 +678,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
{gettext("Choose the state that will appear in the case history.")}
|
{gettext("Choose the state that will appear in the case history.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<.input
|
||||||
|
field={form[:assigned_to_id]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Assigned operator")}
|
||||||
|
options={assignment_options(@support_assignees)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<.input
|
<.input
|
||||||
field={form[:response]}
|
field={form[:response]}
|
||||||
|
|
@ -505,7 +745,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
|
||||||
{gettext("Load more")}
|
{gettext("Load more")}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</Layouts.app>
|
</AdminComponents.shell>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -178,10 +178,35 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
live "/reports", ReportLive, :index
|
live "/reports", ReportLive, :index
|
||||||
end
|
end
|
||||||
|
|
||||||
|
live_session :staff_dashboard,
|
||||||
|
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :dashboard_view}}] do
|
||||||
|
live "/admin", AdminDashboardLive, :index
|
||||||
|
end
|
||||||
|
|
||||||
|
live_session :staff_users,
|
||||||
|
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :users_view}}] do
|
||||||
|
live "/admin/users", AdminUsersLive, :index
|
||||||
|
end
|
||||||
|
|
||||||
|
live_session :staff_audit,
|
||||||
|
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :audit_view}}] do
|
||||||
|
live "/admin/audit", AdminAuditLive, :index
|
||||||
|
end
|
||||||
|
|
||||||
live_session :moderation,
|
live_session :moderation,
|
||||||
on_mount: [{WhoNeedHelpWeb.UserAuth, :ensure_moderator}] do
|
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :moderation_view}}] do
|
||||||
live "/moderation", ModerationLive, :index
|
live "/moderation", ModerationLive, :index
|
||||||
|
end
|
||||||
|
|
||||||
|
live_session :analytics,
|
||||||
|
on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :analytics_view}}] do
|
||||||
live "/analytics", ProductAnalyticsLive, :index
|
live "/analytics", ProductAnalyticsLive, :index
|
||||||
|
end
|
||||||
|
|
||||||
|
live_session :support_operations,
|
||||||
|
on_mount: [
|
||||||
|
{WhoNeedHelpWeb.UserAuth, {:ensure_any_permission, [:support_view, :legal_view]}}
|
||||||
|
] do
|
||||||
live "/support/operations", SupportOperationsLive, :index
|
live "/support/operations", SupportOperationsLive, :index
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -294,16 +294,57 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_mount(:ensure_moderator, _params, session, socket) do
|
def on_mount(:ensure_moderator, _params, session, socket) do
|
||||||
|
on_mount({:ensure_permission, :moderation_view}, %{}, session, socket)
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_mount({:ensure_permission, permission}, _params, session, socket) do
|
||||||
case live_scope(session) do
|
case live_scope(session) do
|
||||||
%Scope{user: %Accounts.User{} = user} = scope ->
|
%Scope{user: %Accounts.User{} = user} = scope ->
|
||||||
if Accounts.moderator_authorized?(user) do
|
if Accounts.authorized?(user, permission) do
|
||||||
set_live_locale(scope, session)
|
set_live_locale(scope, session)
|
||||||
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
||||||
else
|
else
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> Phoenix.Component.assign(:current_scope, scope)
|
|> Phoenix.Component.assign(:current_scope, scope)
|
||||||
|> Phoenix.LiveView.put_flash(:error, gettext("Moderator access is required."))
|
|> Phoenix.LiveView.put_flash(
|
||||||
|
:error,
|
||||||
|
gettext("You do not have access to that workspace.")
|
||||||
|
)
|
||||||
|
|> Phoenix.LiveView.redirect(to: ~p"/requests")
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> Phoenix.Component.assign(:current_scope, nil)
|
||||||
|
|> Phoenix.LiveView.put_flash(
|
||||||
|
:error,
|
||||||
|
gettext("You must log in to access this page.")
|
||||||
|
)
|
||||||
|
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_mount({:ensure_any_permission, permissions}, _params, session, socket)
|
||||||
|
when is_list(permissions) do
|
||||||
|
case live_scope(session) do
|
||||||
|
%Scope{user: %Accounts.User{} = user} = scope ->
|
||||||
|
if Enum.any?(permissions, &Accounts.authorized?(user, &1)) do
|
||||||
|
set_live_locale(scope, session)
|
||||||
|
{:cont, Phoenix.Component.assign(socket, :current_scope, scope)}
|
||||||
|
else
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> Phoenix.Component.assign(:current_scope, scope)
|
||||||
|
|> Phoenix.LiveView.put_flash(
|
||||||
|
:error,
|
||||||
|
gettext("You do not have access to that workspace.")
|
||||||
|
)
|
||||||
|> Phoenix.LiveView.redirect(to: ~p"/requests")
|
|> Phoenix.LiveView.redirect(to: ~p"/requests")
|
||||||
|
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -7,7 +7,9 @@ defmodule WhoNeedHelp.AccountsFixtures do
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias WhoNeedHelp.Accounts
|
alias WhoNeedHelp.Accounts
|
||||||
|
alias WhoNeedHelp.Accounts.StaffRoleAssignment
|
||||||
alias WhoNeedHelp.Accounts.Scope
|
alias WhoNeedHelp.Accounts.Scope
|
||||||
|
alias WhoNeedHelp.Repo
|
||||||
|
|
||||||
def unique_user_email, do: "user#{System.unique_integer()}@example.com"
|
def unique_user_email, do: "user#{System.unique_integer()}@example.com"
|
||||||
def valid_user_password, do: "hello world!"
|
def valid_user_password, do: "hello world!"
|
||||||
|
|
@ -43,6 +45,28 @@ defmodule WhoNeedHelp.AccountsFixtures do
|
||||||
user
|
user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def staff_user_fixture(roles, attrs \\ %{}) when is_list(roles) do
|
||||||
|
user = %{user_fixture(attrs) | authenticated_at: DateTime.utc_now(:second)}
|
||||||
|
|
||||||
|
Enum.each(roles, fn role ->
|
||||||
|
%StaffRoleAssignment{}
|
||||||
|
|> StaffRoleAssignment.changeset(%{user_id: user.id, role: role})
|
||||||
|
|> Repo.insert!()
|
||||||
|
end)
|
||||||
|
|
||||||
|
Accounts.preload_staff_roles(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def grant_staff_roles(user, roles) when is_list(roles) do
|
||||||
|
Enum.each(roles, fn role ->
|
||||||
|
%StaffRoleAssignment{}
|
||||||
|
|> StaffRoleAssignment.changeset(%{user_id: user.id, role: role})
|
||||||
|
|> Repo.insert!(on_conflict: :nothing, conflict_target: [:user_id, :role])
|
||||||
|
end)
|
||||||
|
|
||||||
|
Accounts.preload_staff_roles(user)
|
||||||
|
end
|
||||||
|
|
||||||
def user_scope_fixture do
|
def user_scope_fixture do
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
user_scope_fixture(user)
|
user_scope_fixture(user)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ defmodule WhoNeedHelp.AccountsTest do
|
||||||
alias WhoNeedHelp.Accounts
|
alias WhoNeedHelp.Accounts
|
||||||
|
|
||||||
import WhoNeedHelp.AccountsFixtures
|
import WhoNeedHelp.AccountsFixtures
|
||||||
alias WhoNeedHelp.Accounts.{SocialIdentity, User, UserToken}
|
alias WhoNeedHelp.Accounts.{SocialIdentity, StaffPermissions, User, UserToken}
|
||||||
|
|
||||||
describe "get_user_by_email/1" do
|
describe "get_user_by_email/1" do
|
||||||
test "does not return the user if the email does not exist" do
|
test "does not return the user if the email does not exist" do
|
||||||
|
|
@ -103,11 +103,12 @@ defmodule WhoNeedHelp.AccountsTest do
|
||||||
|> set_password()
|
|> set_password()
|
||||||
|> Ecto.Changeset.change(
|
|> Ecto.Changeset.change(
|
||||||
bio: "A short public bio",
|
bio: "A short public bio",
|
||||||
role: :admin,
|
|
||||||
moderation_note: "private moderator note"
|
moderation_note: "private moderator note"
|
||||||
)
|
)
|
||||||
|> Repo.update!()
|
|> Repo.update!()
|
||||||
|
|
||||||
|
_user = grant_staff_roles(user, [:admin])
|
||||||
|
|
||||||
{:ok, identity} =
|
{:ok, identity} =
|
||||||
Accounts.add_social_identity(user, %{
|
Accounts.add_social_identity(user, %{
|
||||||
"provider" => "telegram",
|
"provider" => "telegram",
|
||||||
|
|
@ -125,7 +126,7 @@ defmodule WhoNeedHelp.AccountsTest do
|
||||||
assert identity_id == identity.id
|
assert identity_id == identity.id
|
||||||
assert is_nil(public_user.email)
|
assert is_nil(public_user.email)
|
||||||
assert is_nil(public_user.hashed_password)
|
assert is_nil(public_user.hashed_password)
|
||||||
assert public_user.role == :user
|
assert %Ecto.Association.NotLoaded{} = public_user.staff_role_assignments
|
||||||
assert is_nil(public_user.moderation_note)
|
assert is_nil(public_user.moderation_note)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -204,6 +205,40 @@ defmodule WhoNeedHelp.AccountsTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "staff roles and permissions" do
|
||||||
|
test "combines permissions from every assigned role and gives administrators all permissions" do
|
||||||
|
combined = staff_user_fixture([:support, :moderator])
|
||||||
|
|
||||||
|
assert Accounts.permission?(combined, :support_manage)
|
||||||
|
assert Accounts.permission?(combined, :moderation_manage)
|
||||||
|
assert Accounts.permission?(combined, :users_moderate)
|
||||||
|
refute Accounts.permission?(combined, :legal_manage)
|
||||||
|
refute Accounts.permission?(combined, :audit_view)
|
||||||
|
|
||||||
|
admin = staff_user_fixture([:admin])
|
||||||
|
|
||||||
|
assert Enum.all?(StaffPermissions.permissions(), &Accounts.permission?(admin, &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a restricted staff account keeps assignments but loses effective authorization" do
|
||||||
|
staff = staff_user_fixture([:support, :legal])
|
||||||
|
assert Accounts.authorized?(staff, :support_manage)
|
||||||
|
assert Accounts.authorized?(staff, :legal_manage)
|
||||||
|
|
||||||
|
restricted =
|
||||||
|
staff
|
||||||
|
|> User.moderation_changeset(%{
|
||||||
|
"moderation_status" => "restricted",
|
||||||
|
"moderation_note" => "Temporary staff access restriction"
|
||||||
|
})
|
||||||
|
|> Repo.update!()
|
||||||
|
|
||||||
|
assert Accounts.staff_roles(restricted) == [:legal, :support]
|
||||||
|
refute Accounts.authorized?(restricted, :support_manage)
|
||||||
|
refute Accounts.authorized?(restricted, :legal_manage)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "profile URL validation" do
|
describe "profile URL validation" do
|
||||||
test "accepts only encrypted thank-you and social profile URLs" do
|
test "accepts only encrypted thank-you and social profile URLs" do
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
|
|
|
||||||
|
|
@ -668,10 +668,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "activity message reports expose only the linked group to audited moderators", context do
|
test "activity message reports expose only the linked group to audited moderators", context do
|
||||||
moderator =
|
moderator = staff_user_fixture([:moderator], display_name: "Activity moderator")
|
||||||
user_fixture(display_name: "Activity moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
attrs = Map.put(context.attrs, "capacity", 3)
|
attrs = Map.put(context.attrs, "capacity", 3)
|
||||||
{:ok, activity} = Activities.create_activity(context.organizer_scope, attrs)
|
{:ok, activity} = Activities.create_activity(context.organizer_scope, attrs)
|
||||||
|
|
@ -738,10 +735,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "moderators can hide and restore a reported activity", context do
|
test "moderators can hide and restore a reported activity", context do
|
||||||
moderator =
|
moderator = staff_user_fixture([:moderator], display_name: "Activity moderator")
|
||||||
user_fixture(display_name: "Activity moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
moderator_scope = user_scope_fixture(moderator)
|
moderator_scope = user_scope_fixture(moderator)
|
||||||
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
|
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,8 @@ defmodule WhoNeedHelp.ProductAnalyticsTest do
|
||||||
assert ProductAnalytics.paginate(ordinary).entries == []
|
assert ProductAnalytics.paginate(ordinary).entries == []
|
||||||
|
|
||||||
moderator =
|
moderator =
|
||||||
user_fixture()
|
[:analyst]
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|> staff_user_fixture()
|
||||||
|> Repo.update!()
|
|
||||||
|> user_scope_fixture()
|
|> user_scope_fixture()
|
||||||
|
|
||||||
assert [%DailyMetric{metric: "request.created", count: 2}] =
|
assert [%DailyMetric{metric: "request.created", count: 2}] =
|
||||||
|
|
|
||||||
|
|
@ -173,10 +173,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
assert request.contact_verified_at
|
assert request.contact_verified_at
|
||||||
assert_email_sent()
|
assert_email_sent()
|
||||||
|
|
||||||
moderator =
|
moderator = staff_user_fixture([:support, :legal])
|
||||||
user_fixture()
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
assert_email_sent()
|
assert_email_sent()
|
||||||
|
|
||||||
|
|
@ -206,6 +203,64 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "support and legal cases can be assigned only to active staff for the matching queue" do
|
||||||
|
requester = user_fixture()
|
||||||
|
requester_scope = user_scope_fixture(requester)
|
||||||
|
operator = staff_user_fixture([:support, :legal], display_name: "Queue operator")
|
||||||
|
support_assignee = staff_user_fixture([:support], display_name: "Support assignee")
|
||||||
|
legal_assignee = staff_user_fixture([:legal], display_name: "Legal assignee")
|
||||||
|
ordinary = user_fixture(display_name: "Ordinary account")
|
||||||
|
operator_scope = user_scope_fixture(operator)
|
||||||
|
|
||||||
|
assert {:ok, support_request} =
|
||||||
|
Support.create_request(requester_scope, %{
|
||||||
|
"kind" => "technical_issue",
|
||||||
|
"subject" => "Assign this support conversation",
|
||||||
|
"details" => "This verified support case should be assigned to a support operator."
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:error, :invalid_assignee} =
|
||||||
|
Support.moderate(operator_scope, support_request.id, %{
|
||||||
|
"status" => "reviewing",
|
||||||
|
"assigned_to_id" => ordinary.id
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, assigned_support} =
|
||||||
|
Support.moderate(operator_scope, support_request.id, %{
|
||||||
|
"status" => "reviewing",
|
||||||
|
"assigned_to_id" => support_assignee.id
|
||||||
|
})
|
||||||
|
|
||||||
|
assert assigned_support.assigned_to_id == support_assignee.id
|
||||||
|
|
||||||
|
assert {:ok, notice} =
|
||||||
|
ContentRemoval.create_notice(requester_scope, :general, %{
|
||||||
|
"category" => "privacy_violation",
|
||||||
|
"submitter_name" => "Notice submitter",
|
||||||
|
"relationship" => "self",
|
||||||
|
"content_locations" => "https://example.test/requests/legal-assignment",
|
||||||
|
"explanation" =>
|
||||||
|
"This notice exercises assignment of a verified legal-review workflow.",
|
||||||
|
"electronic_signature" => "Notice submitter",
|
||||||
|
"good_faith" => "true",
|
||||||
|
"accurate_complete" => "true"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:error, :invalid_assignee} =
|
||||||
|
ContentRemoval.moderate(operator_scope, notice.id, %{
|
||||||
|
"status" => "reviewing",
|
||||||
|
"assigned_to_id" => support_assignee.id
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, assigned_notice} =
|
||||||
|
ContentRemoval.moderate(operator_scope, notice.id, %{
|
||||||
|
"status" => "reviewing",
|
||||||
|
"assigned_to_id" => legal_assignee.id
|
||||||
|
})
|
||||||
|
|
||||||
|
assert assigned_notice.assigned_to_id == legal_assignee.id
|
||||||
|
end
|
||||||
|
|
||||||
test "requester replies reopen a finished case and preserve the full conversation history" do
|
test "requester replies reopen a finished case and preserve the full conversation history" do
|
||||||
previous = Application.get_env(:who_need_help, :support_inbox_address)
|
previous = Application.get_env(:who_need_help, :support_inbox_address)
|
||||||
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
|
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
|
||||||
|
|
@ -440,9 +495,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp moderator_scope do
|
defp moderator_scope do
|
||||||
user_fixture()
|
staff_user_fixture([:support, :legal])
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|> user_scope_fixture()
|
|> user_scope_fixture()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
|
|
||||||
import WhoNeedHelp.AccountsFixtures
|
import WhoNeedHelp.AccountsFixtures
|
||||||
|
|
||||||
alias WhoNeedHelp.{Catalog, Help, Messaging, Release, Tracking, Trust}
|
alias WhoNeedHelp.{Accounts, Catalog, Help, Messaging, Release, Tracking, Trust}
|
||||||
alias WhoNeedHelp.Help.Assignment
|
alias WhoNeedHelp.Help.Assignment
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
alias WhoNeedHelp.Tracking.Position
|
alias WhoNeedHelp.Tracking.Position
|
||||||
|
|
@ -86,10 +86,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
outsider = user_fixture(display_name: "Report history outsider")
|
outsider = user_fixture(display_name: "Report history outsider")
|
||||||
assert [] = Trust.list_reports_for_user(user_scope_fixture(outsider))
|
assert [] = Trust.list_reports_for_user(user_scope_fixture(outsider))
|
||||||
|
|
||||||
moderator =
|
moderator = staff_user_fixture([:moderator], display_name: "Report history moderator")
|
||||||
user_fixture(display_name: "Report history moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
assert {:ok, resolved} =
|
assert {:ok, resolved} =
|
||||||
Trust.moderate_report(user_scope_fixture(moderator), report.id, %{
|
Trust.moderate_report(user_scope_fixture(moderator), report.id, %{
|
||||||
|
|
@ -113,15 +110,9 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "forged moderation identifiers return not found without crashing", context do
|
test "forged moderation identifiers return not found without crashing", context do
|
||||||
moderator =
|
moderator = staff_user_fixture([:moderator], display_name: "Moderator")
|
||||||
user_fixture(display_name: "Moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
admin =
|
admin = staff_user_fixture([:admin], display_name: "Administrator")
|
||||||
user_fixture(display_name: "Administrator")
|
|
||||||
|> Ecto.Changeset.change(role: :admin)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
moderator_scope = user_scope_fixture(moderator)
|
moderator_scope = user_scope_fixture(moderator)
|
||||||
admin_scope = user_scope_fixture(admin)
|
admin_scope = user_scope_fixture(admin)
|
||||||
|
|
@ -136,7 +127,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
assert {:error, :not_found} = Trust.hide_activity(moderator_scope, id, "review")
|
assert {:error, :not_found} = Trust.hide_activity(moderator_scope, id, "review")
|
||||||
assert {:error, :not_found} = Trust.restore_activity(moderator_scope, id)
|
assert {:error, :not_found} = Trust.restore_activity(moderator_scope, id)
|
||||||
assert {:error, :not_found} = Trust.moderate_user(moderator_scope, id, %{})
|
assert {:error, :not_found} = Trust.moderate_user(moderator_scope, id, %{})
|
||||||
assert {:error, :not_found} = Trust.moderate_role(admin_scope, id, %{})
|
assert {:error, :not_found} = Trust.set_staff_roles(admin_scope, id, [])
|
||||||
assert {:error, :not_found} = Catalog.approve_proposal(moderator_scope, id, %{})
|
assert {:error, :not_found} = Catalog.approve_proposal(moderator_scope, id, %{})
|
||||||
assert {:error, :not_found} = Catalog.reject_proposal(moderator_scope, id, "review")
|
assert {:error, :not_found} = Catalog.reject_proposal(moderator_scope, id, "review")
|
||||||
|
|
||||||
|
|
@ -154,10 +145,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "hiding a request requires a moderation reason", context do
|
test "hiding a request requires a moderation reason", context do
|
||||||
moderator =
|
moderator = staff_user_fixture([:moderator], display_name: "Reason moderator")
|
||||||
user_fixture(display_name: "Reason moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
||||||
|
|
||||||
|
|
@ -169,10 +157,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "category moderation cannot cross help and activity taxonomies", context do
|
test "category moderation cannot cross help and activity taxonomies", context do
|
||||||
moderator =
|
moderator = staff_user_fixture([:moderator], display_name: "Category moderator")
|
||||||
user_fixture(display_name: "Category moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
moderator_scope = user_scope_fixture(moderator)
|
moderator_scope = user_scope_fixture(moderator)
|
||||||
activity_category = Catalog.list_categories(:activity) |> List.first()
|
activity_category = Catalog.list_categories(:activity) |> List.first()
|
||||||
|
|
@ -334,10 +319,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "reports expose only linked evidence to moderators and audit that access", context do
|
test "reports expose only linked evidence to moderators and audit that access", context do
|
||||||
moderator =
|
moderator = staff_user_fixture([:moderator], display_name: "Moderator")
|
||||||
user_fixture(display_name: "Moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
moderator_scope = user_scope_fixture(moderator)
|
moderator_scope = user_scope_fixture(moderator)
|
||||||
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
||||||
|
|
@ -669,55 +651,39 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "only an administrator can change roles and the last admin is protected", context do
|
test "only an administrator can change roles and the last admin is protected", context do
|
||||||
moderator =
|
moderator = staff_user_fixture([:moderator], display_name: "Moderator")
|
||||||
user_fixture(display_name: "Moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
admin =
|
admin = staff_user_fixture([:admin], display_name: "Administrator")
|
||||||
user_fixture(display_name: "Administrator")
|
|
||||||
|> Ecto.Changeset.change(role: :admin)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
assert {:error, :forbidden} =
|
assert {:error, :forbidden} =
|
||||||
Trust.moderate_role(user_scope_fixture(moderator), context.helper.id, %{
|
Trust.set_staff_roles(user_scope_fixture(moderator), context.helper.id, ["moderator"])
|
||||||
"role" => "moderator"
|
|
||||||
})
|
|
||||||
|
|
||||||
assert {:ok, promoted} =
|
assert {:ok, promoted} =
|
||||||
Trust.moderate_role(user_scope_fixture(admin), context.helper.id, %{
|
Trust.set_staff_roles(user_scope_fixture(admin), context.helper.id, [
|
||||||
"role" => "moderator"
|
"support",
|
||||||
})
|
"moderator"
|
||||||
|
])
|
||||||
|
|
||||||
assert promoted.role == :moderator
|
assert Accounts.loaded_staff_roles(promoted) == [:moderator, :support]
|
||||||
|
|
||||||
assert {:error, :last_admin} =
|
assert {:error, :last_admin} =
|
||||||
Trust.moderate_role(user_scope_fixture(admin), admin.id, %{"role" => "user"})
|
Trust.set_staff_roles(user_scope_fixture(admin), admin.id, [])
|
||||||
|
|
||||||
assert Repo.exists?(
|
assert Repo.exists?(
|
||||||
from event in AuditEvent,
|
from event in AuditEvent,
|
||||||
where:
|
where:
|
||||||
event.actor_id == ^admin.id and event.target_id == ^context.helper.id and
|
event.actor_id == ^admin.id and event.target_id == ^context.helper.id and
|
||||||
event.action == "user.role_changed"
|
event.action == "user.staff_roles_changed"
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "restricted moderators lose authorization and moderators cannot suspend administrators",
|
test "restricted moderators lose authorization and moderators cannot suspend administrators",
|
||||||
context do
|
context do
|
||||||
admin =
|
admin = staff_user_fixture([:admin], display_name: "Administrator")
|
||||||
user_fixture(display_name: "Administrator")
|
|
||||||
|> Ecto.Changeset.change(role: :admin)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
moderator =
|
moderator = staff_user_fixture([:moderator], display_name: "Moderator")
|
||||||
user_fixture(display_name: "Moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
active_moderator =
|
active_moderator = staff_user_fixture([:moderator], display_name: "Active moderator")
|
||||||
user_fixture(display_name: "Active moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
assert WhoNeedHelp.Accounts.moderator_authorized?(moderator)
|
assert WhoNeedHelp.Accounts.moderator_authorized?(moderator)
|
||||||
|
|
||||||
|
|
@ -776,15 +742,9 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "moderators cannot restrict staff and restrictions require an internal note", context do
|
test "moderators cannot restrict staff and restrictions require an internal note", context do
|
||||||
admin =
|
admin = staff_user_fixture([:admin], display_name: "Staff administrator")
|
||||||
user_fixture(display_name: "Staff administrator")
|
|
||||||
|> Ecto.Changeset.change(role: :admin)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
moderator =
|
moderator = staff_user_fixture([:moderator], display_name: "Staff moderator")
|
||||||
user_fixture(display_name: "Staff moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
assert {:error, :forbidden} =
|
assert {:error, :forbidden} =
|
||||||
Trust.moderate_user(user_scope_fixture(moderator), admin.id, %{
|
Trust.moderate_user(user_scope_fixture(moderator), admin.id, %{
|
||||||
|
|
@ -813,7 +773,8 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
test "the first administrator bootstrap is one-time and audited", context do
|
test "the first administrator bootstrap is one-time and audited", context do
|
||||||
assert {:ok, admin} = Release.bootstrap_admin(context.helper.email)
|
assert {:ok, admin} = Release.bootstrap_admin(context.helper.email)
|
||||||
assert admin.id == context.helper.id
|
assert admin.id == context.helper.id
|
||||||
assert admin.role == :admin
|
assert admin.roles == [:admin]
|
||||||
|
assert Accounts.staff_roles(context.helper) == [:admin]
|
||||||
|
|
||||||
assert {:error, :admin_already_exists} =
|
assert {:error, :admin_already_exists} =
|
||||||
Release.bootstrap_admin(context.requester.email)
|
Release.bootstrap_admin(context.requester.email)
|
||||||
|
|
|
||||||
|
|
@ -216,10 +216,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
"details" => "This request verifies the private support conversation flow."
|
"details" => "This request verifies the private support conversation flow."
|
||||||
})
|
})
|
||||||
|
|
||||||
moderator =
|
moderator = staff_user_fixture([:support])
|
||||||
user_fixture()
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> WhoNeedHelp.Repo.update!()
|
|
||||||
|
|
||||||
{:ok, _resolved} =
|
{:ok, _resolved} =
|
||||||
WhoNeedHelp.Support.moderate(user_scope_fixture(moderator), request.id, %{
|
WhoNeedHelp.Support.moderate(user_scope_fixture(moderator), request.id, %{
|
||||||
|
|
@ -269,10 +266,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
"details" => "Both connected support views must refresh without a browser reload."
|
"details" => "Both connected support views must refresh without a browser reload."
|
||||||
})
|
})
|
||||||
|
|
||||||
moderator =
|
moderator = staff_user_fixture([:support])
|
||||||
user_fixture()
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> WhoNeedHelp.Repo.update!()
|
|
||||||
|
|
||||||
{:ok, requester_view, _html} = live(conn, ~p"/support/cases/#{request.id}")
|
{:ok, requester_view, _html} = live(conn, ~p"/support/cases/#{request.id}")
|
||||||
|
|
||||||
|
|
@ -313,10 +307,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders separate support and removal queues for a moderator", %{conn: conn} do
|
test "renders separate support and removal queues for a moderator", %{conn: conn} do
|
||||||
moderator =
|
moderator = staff_user_fixture([:support, :legal])
|
||||||
user_fixture()
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> WhoNeedHelp.Repo.update!()
|
|
||||||
|
|
||||||
{:ok, support_request} =
|
{:ok, support_request} =
|
||||||
WhoNeedHelp.Support.create_request(nil, %{
|
WhoNeedHelp.Support.create_request(nil, %{
|
||||||
|
|
|
||||||
114
test/who_need_help_web/live/admin_live_test.exs
Normal file
114
test/who_need_help_web/live/admin_live_test.exs
Normal 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
|
||||||
|
|
@ -33,10 +33,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
"details" => "Review this request and keep the report private."
|
"details" => "Review this request and keep the report private."
|
||||||
})
|
})
|
||||||
|
|
||||||
moderator =
|
moderator = staff_user_fixture([:moderator], display_name: "Report moderator")
|
||||||
user_fixture(display_name: "Report moderator")
|
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|
||||||
|> Repo.update!()
|
|
||||||
|
|
||||||
{:ok, _report} =
|
{:ok, _report} =
|
||||||
Trust.moderate_report(user_scope_fixture(moderator), report.id, %{
|
Trust.moderate_report(user_scope_fixture(moderator), report.id, %{
|
||||||
|
|
@ -1502,11 +1499,12 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation")
|
assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "moderator dashboard renders reports, signals, proposals, and accounts", %{
|
test "moderator workspace renders reports, signals, and proposals without account management",
|
||||||
conn: conn,
|
%{
|
||||||
user: user
|
conn: conn,
|
||||||
} do
|
user: user
|
||||||
user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!()
|
} do
|
||||||
|
grant_staff_roles(user, [:moderator])
|
||||||
category = Catalog.seed_defaults()
|
category = Catalog.seed_defaults()
|
||||||
requester = user_fixture(display_name: "Requester")
|
requester = user_fixture(display_name: "Requester")
|
||||||
helper = user_fixture(display_name: "Helper")
|
helper = user_fixture(display_name: "Helper")
|
||||||
|
|
@ -1538,11 +1536,10 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
assert html =~ "Reports"
|
assert html =~ "Reports"
|
||||||
assert html =~ "Open abuse signals"
|
assert html =~ "Open abuse signals"
|
||||||
assert html =~ "Category proposals"
|
assert html =~ "Category proposals"
|
||||||
assert html =~ "Accounts"
|
refute html =~ "Users & roles"
|
||||||
assert html =~ "Please review the matched conversation."
|
assert html =~ "Please review the matched conversation."
|
||||||
assert html =~ "Bicycle repair"
|
assert html =~ "Bicycle repair"
|
||||||
refute has_element?(view, "#moderation-user-status-#{user.id}")
|
refute has_element?(view, "#moderation-user-status-#{user.id}")
|
||||||
assert html =~ "Administrator access is required to moderate staff accounts."
|
|
||||||
|
|
||||||
assert has_element?(
|
assert has_element?(
|
||||||
view,
|
view,
|
||||||
|
|
@ -1562,7 +1559,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
user: user,
|
user: user,
|
||||||
scope: participant_scope
|
scope: participant_scope
|
||||||
} do
|
} do
|
||||||
user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!()
|
grant_staff_roles(user, [:moderator])
|
||||||
organizer = user_fixture(display_name: "Activity organizer")
|
organizer = user_fixture(display_name: "Activity organizer")
|
||||||
Catalog.seed_defaults()
|
Catalog.seed_defaults()
|
||||||
activity_category = Catalog.list_categories(:activity) |> List.first()
|
activity_category = Catalog.list_categories(:activity) |> List.first()
|
||||||
|
|
@ -1615,7 +1612,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
conn: conn,
|
conn: conn,
|
||||||
user: user
|
user: user
|
||||||
} do
|
} do
|
||||||
user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!()
|
grant_staff_roles(user, [:moderator])
|
||||||
subject = user_fixture(display_name: "Signal subject")
|
subject = user_fixture(display_name: "Signal subject")
|
||||||
|
|
||||||
for sequence <- 1..25 do
|
for sequence <- 1..25 do
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user